Introduction

Experimental - Documentation Under Active Development

⚠️ Documentation Status

This documentation is under active development and has not been fully reviewed. Some sections may be incomplete, outdated, or contain errors. Several features (Terminal, Canvas, Image, Effects) are marked as experimental with known limitations.

For the most accurate information, see code examples in crates/telex/examples/ and API documentation via cargo doc --open.

Telex Logo

Telex is a terminal UI framework for Rust, inspired by React's component model.

use telex::prelude::*;

struct App;

impl Component for App {
    fn render(&self, cx: Scope) -> View {
        let count = state!(cx, || 0);

        View::vstack()
            .child(View::text(format!("Count: {}", count.get())))
            .child(
                View::button()
                    .label("Increment")
                    .on_press(with!(count => move || count.update(|n| *n += 1)))
                    .build()
            )
            .build()
    }
}

fn main() {
    telex::run(App).unwrap();
}

Why Telex?

  • Familiar model - If you know React, you know Telex. State, hooks, components.
  • Rust-native - No runtime, no garbage collector. Just Rust.
  • DX-first - Builder pattern or JSX-like macros. Your choice.

Note: Telex is currently keyboard-only. Mouse support (scroll wheel, click-to-focus, widget interactions) is planned but not yet implemented.

What you'll find here

This book takes you from hello world to building real applications:

  1. Getting Started - Installation, first app, core concepts
  2. Building UIs - Layouts, lists, inputs, modals
  3. Dynamic Data - Streams, effects, async loading
  4. Widgets - Tables, trees, tabs, forms, menus
  5. Advanced Patterns - Keyed state, shared state, context

Each chapter builds on the last. Code examples are runnable - you'll find them in crates/telex/examples/.

Running examples

Every concept has a corresponding example:

# Run a specific example
cargo run -p telex-tui --example 02_counter

# See all examples
ls crates/telex/examples/

Quick tour: For a fast overview, run ./run-examples.sh - an interactive menu that lets you browse and run all examples. Great for getting a feel for what Telex can do.

Press F1 in any example to see what it demonstrates.

Let's get started.

Installation

Requirements

  • Rust 1.70 or later
  • A terminal emulator (any modern terminal works)

For canvas/image features (optional):

  • Kitty, Ghostty, or WezTerm (Kitty graphics protocol support)

Add to your project

cargo add telex-tui

Or add to Cargo.toml:

[dependencies]
telex-tui = "0.1"

Verify installation

Create a minimal app:

use telex::prelude::*;

struct App;

impl Component for App {
    fn render(&self, _cx: Scope) -> View {
        View::text("It works!")
    }
}

fn main() {
    telex::run(App).unwrap();
}

Run it:

cargo run

You should see "It works!" in your terminal. Press Ctrl+Q to quit.

Clone the repo (optional)

To run examples and explore the source:

git clone https://github.com/telex-tui/telex-tui
cd telex-tui
cargo run -p telex-tui --example 01_hello_world

Next: Hello World

Hello World

Let's understand what makes a Telex app.

use telex::prelude::*;

struct App;

impl Component for App {
    fn render(&self, _cx: Scope) -> View {
        View::text("Hello, Telex!")
    }
}

fn main() {
    telex::run(App).unwrap();
}

Run with: cargo run -p telex-tui --example 01_hello_world

Breaking it down

The prelude

use telex::prelude::*;

This imports everything you need: Component, View, Scope, the with! macro, and common types.

The component

struct App;

impl Component for App {
    fn render(&self, _cx: Scope) -> View {
        View::text("Hello, Telex!")
    }
}

A component is any struct that implements Component. The render method returns a View - what gets drawn to the screen.

The cx: Scope parameter gives you access to hooks (state, effects, etc). We'll use it in the next chapter.

The view

View::text("Hello, Telex!")

View is an enum with variants for every widget type. View::text() creates a simple text display.

Views compose. You can nest them:

View::vstack()
    .child(View::text("Line 1"))
    .child(View::text("Line 2"))
    .build()

Running the app

fn main() {
    telex::run(App).unwrap();
}

telex::run() starts the event loop. It takes over the terminal, renders your component, and handles input until the user quits (Ctrl+Q).

The render cycle

Telex re-renders your component when:

  • State changes
  • A stream emits a value
  • The terminal resizes

You don't manually trigger renders. Change state, and the UI updates.

Try it

Press F1 in any running example to see what it demonstrates.

Next: State and Buttons

State and Buttons

Static text is nice, but apps need interactivity. Let's add state.

use telex::prelude::*;

struct App;

impl Component for App {
    fn render(&self, cx: Scope) -> View {
        let count = state!(cx, || 0);

        View::vstack()
            .child(View::text(format!("Count: {}", count.get())))
            .child(
                View::button()
                    .label("Increment")
                    .on_press(with!(count => move || count.update(|n| *n += 1)))
                    .build()
            )
            .build()
    }
}

Run with: cargo run -p telex-tui --example 02_counter

The two macros you need

Telex state uses two macros that work together:

state! - Create state

let count = state!(cx, || 0);

Creates a piece of reactive state with an initial value. Works everywhere - no restrictions on where you call it (unlike React hooks).

with! - Attach callbacks

.on_press(with!(count => move || count.update(|n| *n += 1)))

Captures state for use in callbacks. Handles the cloning automatically.

That's it. Use state! to create, with! to attach.

Reading and writing

count.get()                  // read current value
count.set(5)                 // set new value
count.update(|n| *n += 1)    // modify in place

When state changes, Telex re-renders automatically.

Multiple states

Create as many as you need:

let count = state!(cx, || 0);
let name = state!(cx, || String::new());
let enabled = state!(cx, || true);

Buttons

View::button()
    .label("Click me")
    .on_press(callback)
    .build()

Buttons use the builder pattern. Configure with methods, then .build() to finish.

Example: Multiple buttons

let count = state!(cx, || 0);

View::vstack()
    .child(View::text(format!("Count: {}", count.get())))
    .child(
        View::hstack()
            .child(
                View::button()
                    .label("-")
                    .on_press(with!(count => move || count.update(|n| *n -= 1)))
                    .build()
            )
            .child(
                View::button()
                    .label("+")
                    .on_press(with!(count => move || count.update(|n| *n += 1)))
                    .build()
            )
            .build()
    )
    .build()

Each button gets its own with! callback, but they share the same count state.

Under the hood

state! and with! are just conveniences:

// state! expands to:
let count = cx.use_state_keyed::</* auto key */, _>(|| 0);

// with! expands to:
let count_clone = count.clone();
move || count_clone.update(...)

Footnote: There's also cx.use_state_keyed::<Key, _>(|| init) for when you need shared state across call sites — multiple places in your code that access the same state by using the same key type. For everything else, state!() is the way to go.

Next: Styling

Styling

Telex styling works in two layers:

  1. Themes - Pick one, and all widgets use it automatically. Buttons, inputs, selections, borders - they all pull from the theme's semantic colors (primary, error, success, etc.).

  2. Inline overrides - For when you need something specific: an error message in red, a heading in bold, a muted hint.

Most of the time, you just pick a theme and go. Inline styling is for exceptions.


Inline styling

Override colors and attributes on specific text:

View::styled_text("Important!")
    .bold()
    .color(Color::Red)
    .build()

Run with: cargo run -p telex-tui --example 03_theme_switcher

Text attributes

View::styled_text("Hello")
    .bold()
    .dim()
    .italic()
    .underline()
    .color(Color::Cyan)
    .build()

Chain style methods, then .build().

Colors

use telex::Color;

Color::Red
Color::Green
Color::Blue
Color::Yellow
Color::Cyan
Color::Magenta
Color::White
Color::DarkGrey
Color::Rgb { r: 255, g: 128, b: 0 }  // custom RGB

Themes

Themes define semantic colors that widgets use automatically - you don't style each button, the theme handles it.

telex::run_with_theme(App, telex::theme::Theme::nord()).unwrap();

Available themes:

ThemeStyle
Theme::dark()Dark background (default)
Theme::light()Light background
Theme::nord()Nord
Theme::dracula()Dracula
Theme::gruvbox_dark()Gruvbox
Theme::solarized_dark()Solarized
Theme::tokyo_night()Tokyo Night
Theme::monokai()Monokai
Theme::catppuccin_mocha()Catppuccin (dark)
Theme::catppuccin_latte()Catppuccin (light)
Theme::rose_pine()Rosé Pine
Theme::hax0r_green()Monochrome green
Theme::hax0r_blue()Monochrome cyan

Switch themes at runtime:

use telex::theme::{set_theme, Theme};

set_theme(Theme::dracula());

Boxed containers

Add borders and padding with View::boxed():

View::boxed()
    .border(true)
    .padding(1)
    .child(View::text("Boxed content"))
    .build()

Next: Layouts

Layouts

Telex uses a flexbox-inspired layout model. If you've used CSS flexbox or SwiftUI stacks, you'll feel at home.

The mental model

Layout in Telex works like this:

  1. Stacks arrange children along one axis - VStack goes top-to-bottom, HStack goes left-to-right
  2. Children have intrinsic sizes - a button is 1 line tall, text wraps to fit width
  3. Flex distributes extra space - children with flex(1) grow to fill remaining room
  4. Constraints set boundaries - min_height, max_height limit how much a child can grow or shrink

The algorithm is simple: measure fixed children first, then divide leftover space among flexible children proportionally.

┌─────────────────────────────┐
│ Header (intrinsic: 1 line)  │  ← fixed
├─────────────────────────────┤
│                             │
│ Content (flex: 1)           │  ← grows to fill
│                             │
├─────────────────────────────┤
│ Footer (intrinsic: 1 line)  │  ← fixed
└─────────────────────────────┘

No absolute positioning. No z-index. No CSS grid. Just stacks, flex, and splits. This keeps the model simple and predictable.


Vertical stack

View::vstack()
    .spacing(1)  // gap between children
    .child(View::text("First"))
    .child(View::text("Second"))
    .child(View::text("Third"))
    .build()

Horizontal stack

View::hstack()
    .spacing(2)
    .child(View::text("Left"))
    .child(View::text("Right"))
    .build()

Flex

Use .flex(1) to make an element fill available space:

View::vstack()
    .child(View::text("Header"))
    .child(
        View::boxed()
            .flex(1)  // fills remaining space
            .child(View::text("Content"))
            .build()
    )
    .child(View::text("Footer"))
    .build()

Spacer and gap

View::spacer()  // fills available space
View::gap(2)    // fixed 2-line gap

Split panes

View::split()
    .horizontal()
    .ratio(0.3)  // 30% left, 70% right
    .first(left_content)
    .second(right_content)
    .build()

Run with: cargo run -p telex-tui --example 13_split_panes

Lists and Selection

Lists are controlled components - you own the data and selection state, the widget handles display and keyboard navigation.

Two separate concerns:

  • Navigation - Arrow keys move selection, fires on_select with the new index
  • Activation - Enter key acts on the selected item (you handle this separately with use_command)

You provide the items and current selection. When the user navigates, you update your state. The widget never mutates your data directly.


let items = vec!["One".to_string(), "Two".to_string(), "Three".to_string()];
let selected = state!(cx, || 0usize);

View::list()
    .items(items)
    .selected(selected.get())
    .on_select(with!(selected => move |idx: usize| selected.set(idx)))
    .build()

Run with: cargo run -p telex-tui --example 05_todo_list

Basic list

The simplest list just displays items:

let items = vec![
    "Learn Telex".to_string(),
    "Build something cool".to_string(),
];

View::list()
    .items(items)
    .build()

The list widget automatically handles:

  • Scrolling when content exceeds viewport
  • Arrow key navigation (↑/↓)
  • Visual highlighting of the selected item

Managing selection

Track which item is selected with state:

let items = state!(cx, || vec!["Apple".to_string(), "Banana".to_string()]);
let selected = state!(cx, || 0usize);

View::list()
    .items(items.get())
    .selected(selected.get())
    .on_select(with!(selected => move |idx: usize| selected.set(idx)))
    .build()

on_select fires when the user navigates with arrow keys. The callback receives the new index.

Responding to activation

Use the use_command hook to bind keyboard shortcuts to actions. This lets you handle the Enter key on the selected item:

let selected_idx = selected.get();

cx.use_command(
    KeyBinding::key(KeyCode::Enter),
    with!(items, selected => move || {
        let idx = selected.get();
        let items_vec = items.get();
        if idx < items_vec.len() {
            // Do something with items_vec[idx]
            println!("Activated: {}", items_vec[idx]);
        }
    })
);

This pattern separates navigation (on_select) from activation (Enter key).

Adding and removing items

Lists work with Vec<T> state. Use .update() to modify:

let items = state!(cx, || Vec::new());

// Add an item
items.update(|v| v.push("New item".to_string()));

// Remove at index
let idx = selected.get();
items.update(|v| {
    if idx < v.len() {
        v.remove(idx);
    }
});

When removing items, adjust the selection to stay in bounds:

items.update(|v| {
    if idx < v.len() {
        v.remove(idx);
        // Move selection up if we removed the last item
        if idx > 0 && idx >= v.len() {
            selected.set(idx - 1);
        }
    }
});

Empty state

Show alternate content when the list is empty:

let items = items.get();

if items.is_empty() {
    View::styled_text("No items yet").dim().build()
} else {
    View::list()
        .items(items)
        .selected(selected.get())
        .on_select(on_select)
        .build()
}

This gives users context rather than showing an empty list.

Working with structured data

Lists work with any type that implements Display:

struct Task {
    name: String,
    done: bool,
}

impl Display for Task {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        let check = if self.done { "✓" } else { " " };
        write!(f, "[{}] {}", check, self.name)
    }
}

let tasks = state!(cx, || vec![
    Task { name: "First".into(), done: false },
    Task { name: "Second".into(), done: true },
]);

View::list()
    .items(tasks.get())
    .selected(selected.get())
    .on_select(on_select)
    .build()

List vs Table vs Tree

Use List when:

  • Displaying a simple collection
  • Items fit on one line
  • Order matters (chronological, priority)

Use Table when:

  • Data has multiple columns
  • You need sortable headers
  • Alignment matters (numbers, dates)

Use Tree when:

  • Data is hierarchical
  • Users need to expand/collapse sections
  • Showing file systems or nested structures

Real-world example

The file browser (example 07) demonstrates:

  • Directory navigation with Enter
  • Parent directory (..) handling
  • Distinguishing files from folders (trailing /)
  • Showing file details in a modal
// In example 07_file_browser.rs
cx.use_command(
    KeyBinding::key(KeyCode::Enter),
    with!(current_path, selected => move || {
        let entry = &entries[selected.get()];
        if entry == ".." {
            // Go to parent directory
        } else if entry.ends_with('/') {
            // Enter directory
        } else {
            // Show file info
        }
    })
);

Tips

Selection bounds: Always check idx < items.len() before accessing. Lists can be empty or shrink.

Preserve selection: When filtering or sorting, decide if selection should track the same item or stay at the same index.

Initial selection: Start at 0, or use .selected(None) if no default selection makes sense.

Performance: Lists render all items. For very large collections (10k+ items), consider virtualization or pagination.

Next: Text Input

Text Input

Text inputs are controlled - you own the value, the widget owns the cursor and keyboard handling.

You provide the current text via .value(). Every keystroke fires on_change with the new text. You decide whether to accept it by updating your state. This gives you full control over validation, transformation, and character limits.


Single-line input

Use TextInput for short text like names, search queries, or form fields:

let name = state!(cx, || String::new());

View::text_input()
    .value(name.get())
    .placeholder("Enter name...")
    .on_change(with!(name => move |s: String| name.set(s)))
    .build()

Run with: cargo run -p telex-tui --example 05_todo_list

The text input widget provides:

  • Keyboard editing (type, backspace, delete)
  • Left/right arrow navigation
  • Cursor positioning
  • Placeholder text when empty

The controlled pattern

Text inputs are controlled - you provide the value and handle changes:

let input = state!(cx, || String::new());

View::text_input()
    .value(input.get())                    // current value
    .on_change(with!(input => move |text: String| {
        input.set(text);                   // update on change
    }))
    .build()

Every keystroke triggers on_change with the new text. You decide whether to accept it by updating state.

This pattern enables:

  • Validation (reject invalid input)
  • Transformation (uppercase, trimming)
  • Character limits
  • Real-time feedback

Handling submit

Use on_submit to respond when the user presses Enter:

View::text_input()
    .value(input.get())
    .placeholder("Type something...")
    .on_change(with!(input => move |text: String| input.set(text)))
    .on_submit(with!(input, items => move || {
        let text = input.get();
        if !text.is_empty() {
            items.update(|v| v.push(text));
            input.set(String::new());  // clear after submit
        }
    }))
    .build()

Common pattern: validate on submit, clear the input if successful.

Arrow key callbacks

TextInput supports on_key_up and on_key_down for command history or autocomplete:

let history = state!(cx, || vec!["ls", "cd ..", "git status"]);
let history_idx = state!(cx, || None::<usize>);

View::text_input()
    .value(input.get())
    .on_change(on_change)
    .on_key_up(with!(history, history_idx, input => move || {
        let h = history.get();
        if h.is_empty() { return; }
        let idx = history_idx.get().map(|i| i.saturating_sub(1)).unwrap_or(h.len() - 1);
        history_idx.set(Some(idx));
        input.set(h[idx].to_string());
    }))
    .on_key_down(with!(history, history_idx, input => move || {
        let h = history.get();
        if let Some(idx) = history_idx.get() {
            if idx + 1 < h.len() {
                history_idx.set(Some(idx + 1));
                input.set(h[idx + 1].to_string());
            }
        }
    }))
    .build()

These fire when the input is focused and up/down arrows are pressed.

Validation example

Only allow numeric input:

let number = state!(cx, || String::new());

View::text_input()
    .value(number.get())
    .placeholder("Numbers only...")
    .on_change(with!(number => move |text: String| {
        // Only update if all chars are digits
        if text.chars().all(|c| c.is_ascii_digit()) {
            number.set(text);
        }
    }))
    .build()

Since you control what goes into state, invalid input is simply ignored.

Character limits

Enforce a maximum length:

View::text_input()
    .value(username.get())
    .on_change(with!(username => move |text: String| {
        if text.chars().count() <= 20 {
            username.set(text);
        }
    }))
    .build()

Important: Use .chars().count() not .len() for character limits. .len() counts bytes, not user-perceived characters. This matters for Unicode: "café" has 4 characters but 5 bytes, and emoji like "👍" are multiple bytes. Using .len() would incorrectly limit Unicode input.

Multi-line input

Use TextArea for longer content like notes, messages, or code:

let content = state!(cx, || String::new());

View::text_area()
    .value(content.get())
    .placeholder("Start typing...")
    .rows(10)  // height in lines
    .on_change(with!(content => move |text: String| content.set(text)))
    .build()

Run with: cargo run -p telex-tui --example 12_text_area

TextArea supports:

  • Multi-line editing with Enter
  • Up/down/left/right navigation
  • Scrolling when content exceeds height
  • Line wrapping

Tracking cursor position

TextArea can report cursor location:

let cursor_line = state!(cx, || 0usize);
let cursor_col = state!(cx, || 0usize);

View::text_area()
    .value(content.get())
    .rows(12)
    .on_change(with!(content => move |text: String| content.set(text)))
    .on_cursor_change(with!(cursor_line, cursor_col => move |line: usize, col: usize| {
        cursor_line.set(line);
        cursor_col.set(col);
    }))
    .build()

Use this to show cursor position, implement syntax highlighting, or track editing location.

Real-time stats

Calculate line/word/char counts as the user types:

let text = content.get();
let line_count = if text.is_empty() { 0 } else { text.lines().count() };
let word_count = text.split_whitespace().count();
let char_count = text.chars().count();

View::text(format!("Lines: {} | Words: {} | Chars: {}",
    line_count, word_count, char_count))

This demonstrates the reactive model - state changes automatically trigger re-render with updated stats.

Initial focus

Make an input focused when the component appears:

View::text_input()
    .value(input.get())
    .focused(true)  // focus on mount
    .on_change(on_change)
    .build()

Useful for dialogs, forms, or any UI where the user's next action is typing.

TextInput vs TextArea

Use TextInput when:

  • Single line of text
  • Short responses (names, URLs, search)
  • Submit on Enter makes sense
  • Forms with multiple fields

Use TextArea when:

  • Multiple lines expected
  • Long-form content (notes, descriptions, messages)
  • Editing structured text (JSON, code, logs)
  • Height can be predetermined

Placeholder text

Show hints when the input is empty:

View::text_input()
    .placeholder("Search files...")
    .value(search.get())
    .on_change(on_change)
    .build()

Placeholders disappear when the user starts typing. Keep them short and actionable.

Common patterns

Clear on submit:

.on_submit(with!(input => move || {
    process_input(input.get());
    input.set(String::new());
}))

Trim whitespace:

.on_submit(with!(input => move || {
    let trimmed = input.get().trim().to_string();
    if !trimmed.is_empty() {
        process_input(trimmed);
    }
    input.set(String::new());
}))

Character counter:

let remaining = 280 - input.get().len();
View::text(format!("{} characters remaining", remaining))

Tips

Don't validate on every keystroke - Let users type freely, validate on submit or blur.

Show errors clearly - If validation fails, show why. Don't silently reject input.

Preserve cursor position - If you transform input, the cursor may jump. Consider whether transformations should happen on change or on submit.

TextArea scrolling - Content longer than rows automatically scrolls. Don't try to fit everything by making rows huge.

Next: Modals

Modals

Modals are declarative - they're always in your view tree, controlled by a visible flag. No imperative "show modal" calls.

When visible:

  • The modal renders as a centered overlay
  • Focus is trapped - Tab stays within the modal, can't reach elements behind it
  • Escape dismisses - fires on_dismiss, you update your visibility state

When the modal closes, focus returns to where it was before.


let show_modal = state!(cx, || false);

View::modal()
    .visible(show_modal.get())
    .title("Confirm")
    .on_dismiss(with!(show_modal => move || show_modal.set(false)))
    .child(View::text("Are you sure?"))
    .build()

Run with: cargo run -p telex-tui --example 23_modal

Basic modal

A modal appears as a centered overlay with a border and optional title:

let show_help = state!(cx, || false);

View::modal()
    .visible(show_help.get())
    .title("Help")
    .on_dismiss(with!(show_help => move || show_help.set(false)))
    .child(
        View::vstack()
            .child(View::text("Press F1 to toggle this help"))
            .child(View::text("Press Escape to close"))
            .build()
    )
    .build()

Modals are always present in the view tree but only rendered when .visible(true).

Opening and closing

Control visibility with state:

// Open
show_modal.set(true);

// Close
show_modal.set(false);

// Toggle
show_modal.update(|v| *v = !*v);

The on_dismiss callback fires when the user presses Escape:

.on_dismiss(with!(show_modal => move || show_modal.set(false)))

Most modals should close on Escape - it's the expected behavior.

Sizing

Set width and height in columns/rows:

View::modal()
    .visible(visible.get())
    .title("Custom Size")
    .width(60)   // 60 columns wide
    .height(30)  // 30 rows tall
    .child(content)
    .build()

If you don't specify size, the modal sizes to its content. For large content, set explicit dimensions and let the content scroll.

Confirm dialogs

Ask for confirmation before destructive actions:

let show_confirm = state!(cx, || false);

let on_yes = with!(show_confirm, items => move || {
    items.update(|v| v.clear());  // destructive action
    show_confirm.set(false);
});

let on_no = with!(show_confirm => move || {
    show_confirm.set(false);
});

View::modal()
    .visible(show_confirm.get())
    .title("Confirm Delete")
    .on_dismiss(on_no.clone())  // Escape = No
    .child(
        View::vstack()
            .child(View::text("Delete all items?"))
            .child(View::text("This cannot be undone."))
            .child(View::gap(1))
            .child(
                View::hstack()
                    .spacing(2)
                    .child(View::button().label("Yes").on_press(on_yes).build())
                    .child(View::button().label("No").on_press(on_no).build())
                    .build()
            )
            .build()
    )
    .build()

Common pattern: Escape key dismisses = same as clicking "No" or "Cancel".

Alert dialogs

Show information that requires acknowledgment:

let show_alert = state!(cx, || false);

let on_ok = with!(show_alert => move || show_alert.set(false));

View::modal()
    .visible(show_alert.get())
    .title("Success")
    .on_dismiss(on_ok.clone())
    .child(
        View::vstack()
            .child(View::styled_text("Operation completed!").color(Color::Green).build())
            .child(View::gap(1))
            .child(View::button().label("OK").on_press(on_ok).build())
            .build()
    )
    .build()

Alerts typically have a single "OK" button. Escape and OK do the same thing.

Modals with forms

Combine modals with text inputs for data entry:

let show_form = state!(cx, || false);
let name = state!(cx, String::new);

let on_save = with!(show_form, name => move || {
    let value = name.get();
    if !value.is_empty() {
        save_to_database(value);
        name.set(String::new());  // clear for next time
        show_form.set(false);
    }
});

let on_cancel = with!(show_form, name => move || {
    name.set(String::new());  // discard changes
    show_form.set(false);
});

View::modal()
    .visible(show_form.get())
    .title("Enter Name")
    .on_dismiss(on_cancel.clone())
    .child(
        View::vstack()
            .child(View::text("Name:"))
            .child(
                View::text_input()
                    .value(name.get())
                    .on_change(with!(name => move |s: String| name.set(s)))
                    .build()
            )
            .child(View::gap(1))
            .child(
                View::hstack()
                    .spacing(2)
                    .child(View::button().label("Save").on_press(on_save).build())
                    .child(View::button().label("Cancel").on_press(on_cancel).build())
                    .build()
            )
            .build()
    )
    .build()

When the user cancels, clear any partial input so the form is fresh next time.

Focus containment

When a modal is visible, Tab navigation stays within the modal. You can't Tab to elements behind it.

Focus automatically returns to the main content when the modal closes.

This "modal focus trap" is automatic - you don't need to implement it. Telex's focus management system treats visible modals as focus boundaries, ensuring keyboard navigation stays within the topmost modal until it's dismissed.

Multiple modals

You can have multiple modals in the view tree:

View::vstack()
    .child(main_content)
    .child(help_modal)
    .child(confirm_modal)
    .child(alert_modal)
    .build()

Only visible modals render. If multiple modals are visible simultaneously, the last one in the tree appears on top.

Common pattern: help modal always comes first, so action modals appear above it.

Help screens with F1

Bind F1 to toggle a help modal:

let show_help = state!(cx, || false);

cx.use_command(
    KeyBinding::key(KeyCode::F(1)),
    with!(show_help => move || show_help.update(|v| *v = !*v))
);

View::modal()
    .visible(show_help.get())
    .title("Help")
    .on_dismiss(with!(show_help => move || show_help.set(false)))
    .child(help_content)
    .build()

F1 is the standard help key in Telex examples. Users expect it.

Clear form state when closing:

let on_dismiss = with!(show_modal, form_data => move || {
    form_data.set(String::new());  // reset
    show_modal.set(false);
});

Preserve state across opens:

// Just close, don't clear
let on_dismiss = with!(show_modal => move || show_modal.set(false));

Choose based on use case. Forms usually clear on cancel, but editing modals might preserve drafts.

Tips

Title clarity - The title should tell users what the modal is for. "Confirm Delete" not just "Confirm".

Escape always works - Never disable on_dismiss. Users expect Escape to close modals.

Single action = Alert - If there's only one button ("OK", "Got it"), it's an alert, not a dialog.

Destructive actions = Confirm - If the action can't be undone, ask for confirmation.

Initial focus - If the modal has a text input, use .focused(true) so the user can start typing immediately.

Don't nest modals - Showing a modal from within a modal is confusing. Close the first before opening the second.

Size for content - If your modal content is dynamic (lists, text areas), set explicit .width() and .height() to prevent the modal from jumping around.

Overlay everything - Modals appear above all other content. If something renders on top of your modal, check the order in your view tree.

Common patterns

Confirmation before quit:

let show_quit_confirm = state!(cx, || false);

// On Ctrl+Q, show confirm instead of quitting
cx.use_command(
    KeyBinding::key(KeyCode::Char('q')).ctrl(true),
    with!(show_quit_confirm => move || show_quit_confirm.set(true))
);

Loading modal:

View::modal()
    .visible(is_loading.get())
    .title("Please wait...")
    .child(View::text("Loading data..."))
    .build()

Don't provide on_dismiss for loading modals - user can't cancel.

Error modal:

View::modal()
    .visible(error.get().is_some())
    .title("Error")
    .on_dismiss(with!(error => move || error.set(None)))
    .child(View::text(error.get().unwrap_or_default()))
    .build()

Next: Streams

Dynamic Data Overview

This section covers three powerful tools for handling dynamic data in your Telex applications. Each serves a different purpose - choose based on your use case.

Quick Decision Guide

Use Streams when:

  • You have continuous background data that updates over time
  • Examples: timers, system monitoring, log tailing, live feeds
  • Data keeps flowing, no clear "done" state
  • Pattern: Background thread yielding values repeatedly

Use Effects when:

  • You need to react to state changes with side effects
  • Examples: logging, saving to disk, sending analytics, cleanup
  • One-time initialization or responding to dependency changes
  • Pattern: Run code after render when state changes

Use Async Data when:

  • You need to load data once with a clear start and end
  • Examples: API calls, database queries, file loading
  • Clear loading → ready/error lifecycle
  • Pattern: Background task with loading state management

Use Channels & Ports when:

  • External threads need to push data into your component
  • You need bidirectional communication with a background system
  • Examples: WebSocket handlers, hardware I/O, message queues
  • Pattern: Spawn thread with sender, drain messages each frame

Use Intervals when:

  • You need periodic callbacks on a fixed schedule
  • Examples: polling, animation ticks, blinking cursor
  • Pattern: Timer fires, callback runs on main thread each frame

Common Patterns

Timer/Clock Display

Use streams - continuous time updates:

let elapsed = stream!(cx, || {
    (0..).map(|s| {
        std::thread::sleep(Duration::from_secs(1));
        s
    })
});

Auto-save

Use effects - save when document changes:

effect!(cx, document.get(), |doc| {
    save_to_file("autosave.txt", doc);
    || {}
});

Loading User Profile

Use async - one-time fetch with loading state:

let profile = async_data!(cx, || {
    fetch_user_from_api()
});

match &profile {
    Async::Loading => View::text("Loading..."),
    Async::Ready(user) => View::text(&user.name),
    Async::Error(e) => View::text(e),
}

Combining Approaches

You can use multiple approaches together:

// Load initial data (async)
let data = async_data!(cx, || fetch_initial_data());

// Save changes (effect)
effect!(cx, data.clone(), |d| {
    if let Async::Ready(items) = d {
        save_to_disk(items);
    }
    || {}
});

// Show live stats (stream)
let stats = stream!(cx, || {
    (0..).map(|_| {
        std::thread::sleep(Duration::from_secs(5));
        get_stats()
    })
});

Next Steps

Start with the chapter that matches your use case:

Streams

Background data sources that update the UI automatically over time.

let counter = stream!(cx, || {
    (0..).map(|i| {
        std::thread::sleep(Duration::from_secs(1));
        i
    })
});

View::text(format!("Count: {}", counter.get()))

Run with: cargo run -p telex-tui --example 04_timer

What are streams?

Streams let you integrate background data sources into your UI. Each value the stream yields triggers a re-render with the new data.

Common uses:

  • Timers and clocks
  • System monitoring (CPU, memory, network)
  • Log tailing
  • Live data feeds
  • Progress tracking

When to Use Streams

Use streams when:

  • Data updates continuously over time
  • You're polling, monitoring, or tailing
  • The data source is external (files, system, network)

Use effects instead when:

  • You need to react to state changes
  • You're doing one-time initialization
  • You need cleanup logic (timers, subscriptions)

Use async instead when:

  • One-time data fetch (API call, database query)
  • Clear start and end (loading → ready/error)

See Overview for more guidance on choosing the right approach.

Creating a stream

Use the stream! macro with a closure that returns an iterator:

let elapsed = stream!(cx, || {
    (0u64..).map(|s| {
        std::thread::sleep(Duration::from_secs(1));
        s
    })
});

The stream runs in a background thread. Each time the iterator yields a value, Telex triggers a re-render.

Reading stream values

Use .get() to read the latest value:

let seconds = elapsed.get();
View::text(format!("Elapsed: {}s", seconds))

.get() returns the most recent value yielded by the stream.

Checking stream status

Use .is_loading() to check if the stream is still active:

let is_running = stream.is_loading();

if is_running {
    View::styled_text(" ●").color(Color::Green).build()  // active
} else {
    View::styled_text(" ○").dim().build()  // stopped
}

A stream is "loading" while it's producing values. Once the iterator ends or the component unmounts, is_loading() returns false.

Multiple streams

You can create multiple independent streams:

let cpu = stream!(cx, || {
    (0..).map(|_| {
        std::thread::sleep(Duration::from_millis(500));
        get_cpu_usage()
    })
});

let memory = stream!(cx, || {
    (0..).map(|_| {
        std::thread::sleep(Duration::from_millis(800));
        get_memory_usage()
    })
});

let network = stream!(cx, || {
    (0..).map(|_| {
        std::thread::sleep(Duration::from_millis(300));
        get_network_traffic()
    })
});

Run with: cargo run -p telex-tui --example 08_system_monitor

Each stream runs independently in its own thread. They update at different rates and don't block each other.

Stream lifecycle

Creation: The stream starts when the component first renders and calls stream!.

Updates: Each yielded value triggers a re-render. The UI shows the latest value.

Cleanup: When the component unmounts, the stream is automatically stopped and the background thread is cleaned up.

You don't manually start, stop, or manage stream threads.

Infinite vs finite streams

Infinite streams run until the component unmounts:

let timer = stream!(cx, || {
    (0..).map(|i| {  // infinite iterator
        std::thread::sleep(Duration::from_secs(1));
        i
    })
});

Finite streams stop after producing all values:

let countdown = stream!(cx, || {
    (0..=10).rev().map(|i| {  // 10, 9, 8, ..., 0
        std::thread::sleep(Duration::from_secs(1));
        i
    })
});

// After 11 seconds, is_loading() returns false

Reactive updates

Stream values are reactive. Render automatically when they change:

let cpu_val = cpu.get();

let color = if cpu_val > 80 {
    Color::Red
} else if cpu_val > 50 {
    Color::Yellow
} else {
    Color::Green
};

View::styled_text(format!("CPU: {}%", cpu_val))
    .color(color)
    .build()

Each time cpu yields a new value, the color recalculates and the UI updates.

Error handling

If your stream can fail, yield Result values:

let data = stream!(cx, || {
    (0..).map(|_| {
        std::thread::sleep(Duration::from_secs(1));
        fetch_data()  // returns Result<T, E>
    })
});

match data.get() {
    Ok(value) => View::text(format!("Data: {}", value)),
    Err(e) => View::styled_text(format!("Error: {}", e)).color(Color::Red).build(),
}

Progress indicators

Combine finite streams with progress bars:

let progress = stream!(cx, || {
    (0..=100).map(|i| {
        std::thread::sleep(Duration::from_millis(50));
        i
    })
});

let pct = progress.get();
let is_done = !progress.is_loading();

View::vstack()
    .child(View::text(format!("Progress: {}%", pct)))
    .child(progress_bar(pct))
    .child(if is_done {
        View::text("Complete!")
    } else {
        View::text("Working...")
    })
    .build()

Real-world patterns

Log viewer:

let logs = stream!(cx, || {
    std::fs::File::open("/var/log/app.log")
        .and_then(|file| {
            std::io::BufReader::new(file)
                .lines()
                .collect::<Result<Vec<_>, _>>()
        })
        .unwrap_or_default()
        .into_iter()
});

System stats:

let stats = stream!(cx, || {
    (0..).map(|_| {
        std::thread::sleep(Duration::from_secs(2));
        SystemStats {
            cpu: get_cpu(),
            memory: get_memory(),
            disk: get_disk(),
        }
    })
});

Periodic API polling:

let api_data = stream!(cx, || {
    (0..).map(|_| {
        std::thread::sleep(Duration::from_secs(30));
        fetch_from_api()
    })
});

Performance considerations

Update frequency: Don't yield faster than the UI can render. Sleeping for at least 100-300ms between yields is reasonable.

Thread overhead: Each stream is a thread. Dozens of streams is fine, hundreds might be excessive.

Data size: Stream values are cloned when accessed with .get(). Keep them reasonably sized, or wrap large data in Rc<T>.

Blocking operations: Stream closures run in background threads, so blocking operations (file I/O, network calls) are fine.

Tips

Sleep on the first iteration - Streams yield their first value immediately. If your stream sleeps 1 second between yields, put the sleep at the start to avoid an instant first value followed by a delay.

Finite streams for progress - If your task has a known endpoint, use a finite iterator and check is_loading() to detect completion.

Channels for external events - If you need to push data from outside (another thread, a library callback), use the channel! macro instead of wrapping a channel in a stream:

let ch = channel!(cx, String);
let tx = ch.tx();  // WakingSender — wakes event loop instantly
// Pass tx to external code, read with ch.get() in render

See Channels & Ports for full details.

Don't use streams for user input - User actions should go through state and callbacks, not streams. Streams are for external data sources.

Next: Channels & Ports

Async Data

Load data asynchronously with automatic loading and error state management.

let user = async_data!(cx, || {
    std::thread::sleep(Duration::from_secs(2));
    fetch_user_from_api()
});

match &user {
    Async::Loading => View::text("Loading..."),
    Async::Ready(data) => View::text(format!("Welcome, {}!", data.name)),
    Async::Error(e) => View::styled_text(format!("Error: {}", e)).color(Color::Red).build(),
}

Run with: cargo run -p telex-tui --example 24_async_data

What is async_data!?

async_data! runs a closure in a background thread and returns an Async<T> enum representing the current state:

pub enum Async<T> {
    Loading,           // Task is running
    Ready(T),          // Task succeeded
    Error(String),     // Task failed
}

This handles the common pattern of showing loading spinners, displaying data when ready, and showing errors when things fail.

Basic usage

let data = async_data!(cx, || {
    // This runs in a background thread
    std::thread::sleep(Duration::from_secs(1));

    // Return Result<T, String>
    Ok("Data loaded successfully".to_string())
});

// Pattern match on the state
match &data {
    Async::Loading => View::text("Loading..."),
    Async::Ready(value) => View::text(value),
    Async::Error(err) => View::text(format!("Error: {}", err)),
}

The closure must return Result<T, String> where T is your data type.

Lifecycle

  1. Start: When the component first renders, the async task starts immediately
  2. Loading: Async::Loading until the task completes
  3. Complete: Once finished, becomes either Async::Ready(data) or Async::Error(msg)
  4. Cleanup: If the component unmounts while loading, the task is cancelled

Helper methods

Instead of pattern matching, you can use helper methods:

let data = async_data!(cx, || fetch_data());

// Check state
if data.is_loading() {
    // Show spinner
}

if data.is_error() {
    // Show error banner
}

// Get value if ready
if let Some(value) = data.as_ref().ok() {
    // Use value
}

Multiple async operations

Load multiple things in parallel:

let profile = async_data!(cx, || {
    std::thread::sleep(Duration::from_secs(2));
    Ok(fetch_user_profile())
});

let stats = async_data!(cx, || {
    std::thread::sleep(Duration::from_secs(1));
    Ok(fetch_user_stats())
});

let posts = async_data!(cx, || {
    std::thread::sleep(Duration::from_secs(3));
    Ok(fetch_user_posts())
});

// Each loads independently
// They don't wait for each other

Each async_data! runs in its own thread. They start simultaneously and complete at different times.

Error handling

Return Err to indicate failure:

let data = async_data!(cx, || {
    match fetch_from_api() {
        Ok(data) => Ok(data),
        Err(e) => Err(format!("API error: {}", e)),
    }
});

match &data {
    Async::Error(msg) => View::styled_text(msg).color(Color::Red).build(),
    _ => // ... handle loading and ready states
}

The error message is stored as a String.

Loading UI patterns

Simple spinner:

match &data {
    Async::Loading => View::text("Loading..."),
    Async::Ready(d) => render_data(d),
    Async::Error(e) => render_error(e),
}

Progress bar:

match &profile {
    Async::Loading => View::vstack()
        .child(View::text("Loading profile..."))
        .child(View::text("[==========>          ]"))
        .build(),
    Async::Ready(p) => render_profile(p),
    Async::Error(e) => render_error(e),
}

Overall status:

let all_loaded = !profile.is_loading() && !stats.is_loading() && !posts.is_loading();
let any_error = profile.is_error() || stats.is_error() || posts.is_error();

let status = if any_error {
    "Some requests failed"
} else if all_loaded {
    "All data loaded"
} else {
    "Loading..."
};

View::text(format!("Status: {}", status))

Fetching from APIs

Real-world HTTP example:

let user_data = async_data!(cx, || {
    // Using reqwest or similar
    match reqwest::blocking::get("https://api.example.com/user/123") {
        Ok(response) => match response.json::<User>() {
            Ok(user) => Ok(user),
            Err(e) => Err(format!("Parse error: {}", e)),
        },
        Err(e) => Err(format!("Network error: {}", e)),
    }
});

Database queries

Loading from a database:

let records = async_data!(cx, || {
    match db::fetch_all_records() {
        Ok(data) => Ok(data),
        Err(e) => Err(format!("Database error: {}", e)),
    }
});

match &records {
    Async::Loading => View::text("Querying database..."),
    Async::Ready(rows) => View::list().items(rows.clone()).build(),
    Async::Error(e) => View::text(format!("Query failed: {}", e)),
}

File I/O

Loading configuration files:

let config = async_data!(cx, || {
    match std::fs::read_to_string("config.json") {
        Ok(contents) => match serde_json::from_str(&contents) {
            Ok(cfg) => Ok(cfg),
            Err(e) => Err(format!("Invalid JSON: {}", e)),
        },
        Err(e) => Err(format!("File error: {}", e)),
    }
});

Dependent loading

If one load depends on another, use effect! to trigger the second:

let user_id = state!(cx, || None);
let user_profile = state!(cx, || Async::Loading);

// Load user list
let users = async_data!(cx, || Ok(fetch_users()));

// When a user is selected, load their profile
effect!(cx, user_id.get(), with!(user_profile => move |id| {
    if let Some(id) = id {
        // Trigger profile load
        let profile = load_profile(*id);
        user_profile.set(Async::Ready(profile));
    }
    || {}
}));

This is more complex. For simpler cases, load everything upfront.

Refreshing data

To reload, you need to trigger a re-creation of the async task:

let refresh_key = state!(cx, || 0);

let data = async_data!(cx, {
    let key = refresh_key.get();
    move || {
        let _ = key;  // capture key to make closure depend on it
        fetch_data()
    }
});

// Button to refresh
View::button()
    .label("Refresh")
    .on_press(with!(refresh_key => move || {
        refresh_key.update(|k| *k += 1);  // triggers new async task
    }))
    .build()

Changing the closure's captured values causes async_data! to restart.

Note: This pattern is a workaround - the dummy refresh_key is captured just to force the async task to re-run. A more ergonomic API for refreshing async data is planned for a future version.

Async vs Streams

Use async_data! when:

  • One-time data fetch (API call, database query, file load)
  • Clear start and end (loading → ready/error)
  • User-triggered or component mount triggered

Use stream! when:

  • Continuous data updates (polling, tailing, monitoring)
  • No clear "done" state
  • Data keeps flowing over time

See Streams for continuous data.

Performance tips

Parallel is automatic - Multiple async_data! calls run in parallel without extra work.

Blocking is fine - Async tasks run in background threads, so blocking operations (network, disk, sleep) don't freeze the UI.

Cancellation is automatic - If the component unmounts, background threads are cleaned up.

Don't overuse - Each async_data! is a thread. Dozens is fine, hundreds is excessive.

Common patterns

API with timeout:

let data = async_data!(cx, || {
    let handle = std::thread::spawn(|| fetch_from_api());

    match handle.join_timeout(Duration::from_secs(10)) {
        Ok(Ok(data)) => Ok(data),
        Ok(Err(e)) => Err(format!("Request failed: {}", e)),
        Err(_) => Err("Request timed out".to_string()),
    }
});

Retry logic:

let data = async_data!(cx, || {
    for attempt in 1..=3 {
        match fetch_data() {
            Ok(data) => return Ok(data),
            Err(e) if attempt == 3 => return Err(format!("Failed after 3 attempts: {}", e)),
            Err(_) => std::thread::sleep(Duration::from_secs(1)),
        }
    }
    unreachable!()
});

Graceful degradation:

match &data {
    Async::Loading => render_skeleton(),  // placeholder UI
    Async::Ready(d) => render_full_ui(d),
    Async::Error(_) => render_cached_data(),  // fallback to stale data
}

Tips

Return Ok/Err explicitly - The closure must return Result<T, String>.

Errors are strings - Convert your error types to strings: Err(format!("...", e)).

Loading is the initial state - Even before the thread starts, the state is Async::Loading.

No retry mechanism - If the task fails, it stays failed. Implement retry logic inside the closure.

Pattern match or use helpers - Either match &async_data or .is_loading() / .is_error().

Clone for rendering - Async<T> derefs to &T when Ready, but pattern matching gives you references. Clone if needed for rendering.

Async data is not reactive - Unlike state!, modifying the value inside Async::Ready doesn't trigger re-renders. Async tasks run once.

Next: Tables

Channels & Ports

Receive messages from external threads with zero-latency wake-up.

let ch = channel!(cx, String);

effect_once!(cx, {
    let tx = ch.tx();
    move || {
        std::thread::spawn(move || {
            tx.send("hello from thread".to_string()).ok();
        });
        || {}
    }
});

for msg in ch.get() {
    // handle each message this frame
}

Run with: cargo run -p telex-tui --example 34_channels_and_intervals

What are channels?

Channels let external threads push data into your component. Unlike streams (which pull from an iterator), channels are push-based — any thread with a sender can deliver messages at any time.

Each frame, the run loop drains the channel and your render code sees a clean batch of messages via .get().

channel! — inbound messages

let ch = channel!(cx, MessageType);

Creates a typed inbound channel. Returns a ChannelHandle<T> with:

  • ch.tx() — Returns a WakingSender<T> that can be cloned and sent to any thread
  • ch.get() — Returns a Vec<T> of messages received this frame
  • ch.len() — Number of messages this frame
  • ch.is_empty() — Whether any messages arrived

WakingSender

The sender returned by ch.tx() is special — it wakes the event loop immediately when a message is sent. This means near-zero latency between sending and rendering, instead of waiting for the next 16ms poll cycle.

let tx = ch.tx();

// Clone and send to multiple threads
let tx2 = tx.clone();
std::thread::spawn(move || {
    tx.send("from thread 1".to_string()).ok();
});
std::thread::spawn(move || {
    tx2.send("from thread 2".to_string()).ok();
});

port! — bidirectional communication

let io = port!(cx, InboundType, OutboundType);

Creates a bidirectional port for two-way communication. Returns a PortHandle<In, Out> with:

  • io.rx — A ChannelHandle<In> for inbound messages (same API as channel!)
  • io.tx() — A Sender<Out> for outbound messages
  • io.take_outbound_rx() — Takes the outbound receiver (call once, pass to your thread)

Example: background worker

let worker = port!(cx, WorkerResult, WorkerCommand);

effect_once!(cx, {
    let tx_in = worker.rx.tx();
    let rx_out = worker.take_outbound_rx();
    move || {
        std::thread::spawn(move || {
            if let Some(rx) = rx_out {
                for cmd in rx {
                    let result = process(cmd);
                    tx_in.send(result).ok();
                }
            }
        });
        || {}
    }
});

// Send commands
worker.tx().send(WorkerCommand::Start).ok();

// Read results
for result in worker.rx.get() {
    // handle result
}

When to use channels vs streams

Use channels when:

  • External code pushes data to your component
  • You need bidirectional communication
  • Messages arrive at unpredictable times
  • You want zero-latency wake-up

Use streams when:

  • You're pulling from an iterator (polling, tailing)
  • Data flows one direction continuously
  • The data source is self-contained

Tips

Frame-buffered delivery — Messages accumulate between frames. .get() returns all messages since the last render as a Vec<T>. This means you might get 0, 1, or many messages per frame.

Sender lifetimeWakingSender is Send + Sync and can be cloned freely. When all senders are dropped, the channel still works — it just won't receive new messages.

Order-independent — Like all macros, channel! and port! are keyed by call site. Safe in conditionals.

Next: Intervals

Intervals

Periodic callbacks that run on a fixed schedule.

let ticks = state!(cx, || 0u64);

interval!(cx, Duration::from_secs(1), with!(ticks => move || {
    ticks.update(|n| *n += 1);
}));

View::text(format!("Ticks: {}", ticks.get()))

Run with: cargo run -p telex-tui --example 34_channels_and_intervals

What is interval!?

interval! sets up a repeating timer that fires a callback on the main thread. Under the hood, it spawns a background thread with a WakingSender channel — the timer thread sends a wake-up signal, and the callback runs during the next frame.

interval!(cx, duration, callback);
  • duration — A std::time::Duration for the interval period
  • callback — A closure that runs on the main thread each frame the timer fires

Common uses

Polling

let data = state!(cx, || None);

interval!(cx, Duration::from_secs(5), with!(data => move || {
    // Runs every 5 seconds on the main thread
    if let Ok(fresh) = quick_fetch() {
        data.set(Some(fresh));
    }
}));

Animation

let frame = state!(cx, || 0usize);
let sprites = ["| ", "/ ", "- ", "\\ "];

interval!(cx, Duration::from_millis(100), with!(frame => move || {
    frame.update(|f| *f = (*f + 1) % 4);
}));

View::text(sprites[frame.get()])

Blinking cursor

let visible = state!(cx, || true);

interval!(cx, Duration::from_millis(500), with!(visible => move || {
    visible.update(|v| *v = !*v);
}));

Interval vs stream

Use interval! when:

  • You need periodic side effects (polling, animation)
  • The callback is simple and runs on the main thread
  • You want the timer to fire even if no data changed

Use stream! when:

  • You need to yield data values over time
  • The work runs in a background thread
  • You want loading/completion tracking with .is_loading()

Tips

Main thread execution — The callback runs on the main thread during the render cycle. Keep it fast — don't do heavy computation or blocking I/O in the callback.

Order-independent — Like all macros, interval! is keyed by call site. Safe in conditionals and loops.

Automatic cleanup — The timer thread stops when the component unmounts.

Next: Tables

Tables

Multi-column data displays with sortable headers, row selection, and flexible layouts.

View::table()
    .column("Name")
    .column("Status")
    .column("Value")
    .rows(data)
    .selected(selected.get())
    .on_select(with!(selected => move |idx| selected.set(idx)))
    .build()

Run with: cargo run -p telex-tui --example 17_table

Basic table

A table displays rows of data in aligned columns:

let rows = vec![
    vec!["Alice".to_string(), "Active".to_string(), "100".to_string()],
    vec!["Bob".to_string(), "Inactive".to_string(), "50".to_string()],
    vec!["Carol".to_string(), "Active".to_string(), "75".to_string()],
];

View::table()
    .column("Name")
    .column("Status")
    .column("Score")
    .rows(rows)
    .build()

Each row is a Vec<String>. The number of items in each row should match the number of columns.

Column configuration

Use .column() for simple headers or .column_with() for advanced configuration:

View::table()
    .column("Name")  // flexible width, left-aligned
    .column_with(
        TableColumn::new("Status")
            .width(ColumnWidth::Fixed(12))
    )
    .column_with(
        TableColumn::new("Score")
            .width(ColumnWidth::Fixed(8))
            .align(TextAlign::Right)
    )
    .rows(data)
    .build()

Column width

Fixed width: Exact number of characters

.column_with(TableColumn::new("ID").width(ColumnWidth::Fixed(8)))

Flex width: Proportional share of remaining space

.column_with(TableColumn::new("Description").width(ColumnWidth::Flex(2)))

If you don't specify width, columns default to flexible with equal weight.

Text alignment

.column_with(
    TableColumn::new("Count")
        .align(TextAlign::Right)  // right-align numbers
)

Options: TextAlign::Left (default), TextAlign::Right, TextAlign::Center.

Row selection

Track which row is selected:

let selected = state!(cx, || 0usize);

View::table()
    .column("Name")
    .column("Value")
    .rows(data)
    .selected(selected.get())
    .on_select(with!(selected => move |idx: usize| selected.set(idx)))
    .build()

Users navigate with arrow keys. on_select fires when selection changes.

Row activation

Handle Enter key on a selected row:

let on_activate = with!(selected, data => move |idx: usize| {
    if let Some(row) = data.get(idx) {
        // Do something with the selected row
        show_details(row);
    }
});

View::table()
    .rows(data.get())
    .selected(selected.get())
    .on_select(on_select)
    .on_activate(on_activate)
    .build()

Common use: open a detail view or edit modal for the selected item.

Sorting

Tables support sortable columns. You manage the sort state and re-sort data yourself:

// Track sort: (column_index, ascending)
let sort_state = state!(cx, || None::<(usize, bool)>);

// Sort your data based on state
let mut rows = base_data.clone();
if let Some((col, ascending)) = sort_state.get() {
    rows.sort_by(|a, b| {
        let a_val = a.get(col).unwrap_or(&String::new());
        let b_val = b.get(col).unwrap_or(&String::new());
        if ascending {
            a_val.cmp(b_val)
        } else {
            b_val.cmp(a_val)
        }
    });
}

let on_sort = with!(sort_state => move |col: usize, asc: bool| {
    sort_state.set(Some((col, asc)));
});

View::table()
    .rows(rows)
    .sort(sort_state.get())
    .on_sort(on_sort)
    .build()

The table widget displays sort indicators (▲/▼) in headers. You handle the actual sorting logic.

Numeric sorting

For numeric columns, parse before comparing:

rows.sort_by(|a, b| {
    let a_num = a[col].parse::<i32>().unwrap_or(0);
    let b_num = b[col].parse::<i32>().unwrap_or(0);
    if ascending {
        a_num.cmp(&b_num)
    } else {
        b_num.cmp(&a_num)
    }
});

Custom data types

Convert your structs to rows:

#[derive(Clone)]
struct Pod {
    name: String,
    status: String,
    cpu: String,
    memory: String,
}

let pods = vec![
    Pod { name: "nginx".into(), status: "Running".into(), cpu: "12%".into(), memory: "256Mi".into() },
    Pod { name: "redis".into(), status: "Running".into(), cpu: "8%".into(), memory: "128Mi".into() },
];

// Convert to table rows
let rows: Vec<Vec<String>> = pods.iter()
    .map(|p| vec![p.name.clone(), p.status.clone(), p.cpu.clone(), p.memory.clone()])
    .collect();

View::table()
    .column("Name")
    .column("Status")
    .column("CPU")
    .column("Memory")
    .rows(rows)
    .build()

Column layout example

Mix fixed and flexible columns:

View::table()
    .column_with(TableColumn::new("ID").width(ColumnWidth::Fixed(6)))
    .column("Description")  // flex (fills remaining space)
    .column_with(TableColumn::new("Count").width(ColumnWidth::Fixed(8)).align(TextAlign::Right))
    .column_with(TableColumn::new("Status").width(ColumnWidth::Fixed(10)))
    .rows(data)
    .build()

Result: ID takes 6 chars, Count takes 8, Status takes 10, Description fills the rest.

Empty state

Show a message when there's no data:

if data.is_empty() {
    View::styled_text("No data available").dim().build()
} else {
    View::table()
        .column("Name")
        .column("Value")
        .rows(data)
        .build()
}

Real-world example: Kubernetes pods

From example 17:

let pods = vec![
    vec!["nginx-pod".into(), "Running".into(), "12%".into(), "256Mi".into(), "2h".into()],
    vec!["redis-cache".into(), "Running".into(), "8%".into(), "128Mi".into(), "5d".into()],
    vec!["api-server".into(), "Running".into(), "45%".into(), "512Mi".into(), "1h".into()],
];

View::table()
    .column("NAME")
    .column_with(TableColumn::new("STATUS").width(ColumnWidth::Fixed(12)))
    .column_with(TableColumn::new("CPU").width(ColumnWidth::Fixed(8)).align(TextAlign::Right))
    .column_with(TableColumn::new("MEMORY").width(ColumnWidth::Fixed(10)).align(TextAlign::Right))
    .column_with(TableColumn::new("AGE").width(ColumnWidth::Fixed(8)).align(TextAlign::Right))
    .rows(pods)
    .selected(selected.get())
    .sort(sort_state.get())
    .on_select(on_select)
    .on_sort(on_sort)
    .on_activate(on_activate)
    .build()

This creates a k9s-style pod dashboard with sortable columns and navigation.

Table vs List

Use Table when:

  • Data has multiple attributes (columns)
  • Alignment matters (numbers, dates)
  • Sorting by different fields is needed
  • Comparing values across rows

Use List when:

  • Single attribute per item
  • Items are simple text
  • Order is fixed or chronological
  • No column alignment needed

See Lists for simple collections.

Scrolling

Tables automatically scroll when content exceeds viewport height. The header remains visible while scrolling through rows.

Performance

Tables render all visible rows. For thousands of rows, consider:

  • Pagination (show 50-100 rows at a time)
  • Virtual scrolling (advanced)
  • Filtering to reduce row count

For typical use (hundreds of rows), performance is fine.

Common patterns

Status indicators with color:

let rows: Vec<Vec<String>> = data.iter().map(|item| {
    let status = if item.is_active {
        "✓ Active".to_string()
    } else {
        "✗ Inactive".to_string()
    };
    vec![item.name.clone(), status, item.count.to_string()]
}).collect();

Showing selected row details:

let selected_data = data.get(selected.get()).cloned();

View::vstack()
    .child(table_view)
    .child(if let Some(item) = selected_data {
        View::text(format!("Details: {:?}", item))
    } else {
        View::text("Select a row")
    })
    .build()

Multi-column sorting (secondary sort):

rows.sort_by(|a, b| {
    // Primary sort
    let primary = if ascending {
        a[col].cmp(&b[col])
    } else {
        b[col].cmp(&a[col])
    };

    // Secondary sort (always by first column)
    if primary == std::cmp::Ordering::Equal {
        a[0].cmp(&b[0])
    } else {
        primary
    }
});

Formatting numbers:

let rows: Vec<Vec<String>> = data.iter().map(|item| {
    vec![
        item.name.clone(),
        format!("{:>8}", item.count),  // right-aligned with padding
        format!("{:.2}%", item.percent),  // two decimal places
    ]
}).collect();

Tips

Column count must match - Every row should have the same number of items as you have columns. Extra items are ignored, missing items show as empty.

Right-align numbers - Use TextAlign::Right for numeric columns. It's easier to compare values.

Fixed width for predictable columns - Status, dates, IDs usually have known max width. Use ColumnWidth::Fixed.

Flex for variable content - Names, descriptions, messages should use flexible width to utilize available space.

Sort state is optional - If you don't provide .sort(), the table won't show sort indicators. Sorting is purely visual feedback.

Selection is zero-indexed - First row is index 0. Check bounds when accessing data based on selection.

Headers are always visible - When scrolling, the header row stays at the top.

Unicode in cells works - Emojis, box drawing, CJK characters all work. The table handles grapheme cluster widths correctly. Note: Complex grapheme clusters (emoji with modifiers, combining characters) may impact rendering performance in very large tables (1000+ rows).

Next: Trees

Trees

Hierarchical data structures with expand/collapse functionality for navigating nested content.

let items = vec![
    TreeItem::new("src")
        .icon("📁")
        .expanded(true)
        .child(TreeItem::new("main.rs").icon("📄"))
        .child(TreeItem::new("lib.rs").icon("📄")),
    TreeItem::new("README.md").icon("📝"),
];

View::tree()
    .items(items)
    .selected(selected.get())
    .on_select(with!(selected => move |path| selected.set(path)))
    .build()

Run with: cargo run -p telex-tui --example 16_tree

Basic tree

A tree displays hierarchical data with parent-child relationships:

let items = vec![
    TreeItem::new("Parent")
        .child(TreeItem::new("Child 1"))
        .child(TreeItem::new("Child 2")),
    TreeItem::new("Another Parent"),
];

View::tree()
    .items(items)
    .build()

Each TreeItem can have children, creating nested levels.

TreeItem builder

Build tree items with the fluent API:

TreeItem::new("label")          // required: display text
    .icon("📁")                 // optional: icon before label
    .expanded(true)             // optional: show children (default false)
    .child(TreeItem::new("..")) // optional: add child nodes

Icons

Add visual indicators for different item types:

TreeItem::new("src").icon("📁")           // folder
TreeItem::new("main.rs").icon("📄")       // file
TreeItem::new("Cargo.toml").icon("📦")    // package
TreeItem::new("README.md").icon("📝")     // document

Icons appear before the label. Use any string - emojis, Unicode symbols, or ASCII.

Expand/collapse

Control which nodes show their children:

TreeItem::new("Folder")
    .expanded(true)   // children visible
    .child(TreeItem::new("File 1"))
    .child(TreeItem::new("File 2"))

When .expanded(false) or omitted, children are hidden. The tree shows a collapse indicator (▶/▼).

Selection tracking

Trees use paths to identify items. A path is Vec<usize> - indices from root to the item:

let selected = state!(cx, || vec![0]);  // first root item

View::tree()
    .items(items)
    .selected(selected.get())
    .on_select(with!(selected => move |path: TreePath| {
        selected.set(path);
    }))
    .build()

Path examples:

  • [0] - first root item
  • [0, 2] - third child of first root item
  • [1, 0, 1] - second grandchild of second root item

Expand/collapse with activation

Handle Enter key to toggle expansion:

let expanded_paths = state!(cx, || vec![vec![0]]);  // src/ expanded

let on_activate = with!(expanded_paths => move |path: TreePath| {
    let mut paths = expanded_paths.get();

    if let Some(pos) = paths.iter().position(|p| *p == path) {
        // Already expanded, collapse it
        paths.remove(pos);
    } else {
        // Collapsed, expand it
        paths.push(path);
    }

    expanded_paths.set(paths);
});

View::tree()
    .items(build_tree_with_state(&expanded_paths.get()))
    .on_activate(on_activate)
    .build()

You manage which paths are expanded. Rebuild tree items based on that state.

Dynamic tree building

Rebuild the tree based on expanded state:

fn build_tree(expanded_paths: &[TreePath]) -> Vec<TreeItem> {
    let is_expanded = |path: &[usize]| {
        expanded_paths.iter().any(|p| p == path)
    };

    vec![
        TreeItem::new("src")
            .icon("📁")
            .expanded(is_expanded(&[0]))
            .child(TreeItem::new("main.rs").icon("📄"))
            .child(TreeItem::new("lib.rs").icon("📄")),
        TreeItem::new("tests")
            .icon("📁")
            .expanded(is_expanded(&[1]))
            .child(TreeItem::new("test.rs").icon("📄")),
    ]
}

// In render:
let items = build_tree(&expanded_paths.get());
View::tree().items(items).build()

This pattern lets you control expansion state reactively.

File system trees

Example from Example 16 - a project file browser:

vec![
    TreeItem::new("src")
        .icon("📁")
        .expanded(true)
        .child(
            TreeItem::new("components")
                .icon("📁")
                .child(TreeItem::new("button.rs").icon("📄"))
                .child(TreeItem::new("input.rs").icon("📄"))
        )
        .child(TreeItem::new("main.rs").icon("📄"))
        .child(TreeItem::new("lib.rs").icon("📄")),
    TreeItem::new("tests")
        .icon("📁")
        .child(TreeItem::new("integration.rs").icon("📄")),
    TreeItem::new("Cargo.toml").icon("📦"),
    TreeItem::new("README.md").icon("📝"),
]

Getting item data by path

Navigate the tree to find the selected item:

fn get_item_at_path<'a>(
    items: &'a [TreeItem],
    path: &[usize]
) -> Option<&'a TreeItem> {
    if path.is_empty() {
        return None;
    }

    let mut current_items = items;
    let mut result = None;

    for &idx in path {
        if idx < current_items.len() {
            result = Some(&current_items[idx]);
            current_items = &current_items[idx].children;
        } else {
            return None;
        }
    }

    result
}

// Usage:
let selected_label = get_item_at_path(&items, &selected.get())
    .map(|item| item.label.clone())
    .unwrap_or_else(|| "Nothing".to_string());

This walks the path to retrieve the item.

Nesting levels

Trees can be arbitrarily deep:

TreeItem::new("Level 1")
    .child(
        TreeItem::new("Level 2")
            .child(
                TreeItem::new("Level 3")
                    .child(TreeItem::new("Level 4"))
            )
    )

The tree widget handles indentation automatically - each level is indented further.

Empty trees

Show a message when there's no data:

if items.is_empty() {
    View::styled_text("No items").dim().build()
} else {
    View::tree().items(items).build()
}

Pattern: Conditional Children

For large trees, you can optimize performance by only building children when their parent is expanded. This isn't a built-in API - you implement it by conditionally adding children in your tree-building function:

fn build_tree(expanded_paths: &[TreePath]) -> Vec<TreeItem> {
    let is_expanded = |path: &[usize]| {
        expanded_paths.iter().any(|p| p == path)
    };

    vec![
        TreeItem::new("Folder")
            .expanded(is_expanded(&[0]))
            .children_if(is_expanded(&[0]), || {
                // Only build children if this folder is expanded
                vec![
                    TreeItem::new("File 1"),
                    TreeItem::new("File 2"),
                ]
            })
    ]
}

Replace .children_if() with manual conditionals:

let mut item = TreeItem::new("Folder").expanded(is_expanded(&[0]));

if is_expanded(&[0]) {
    item = item
        .child(TreeItem::new("File 1"))
        .child(TreeItem::new("File 2"));
}

This pattern avoids building large subtrees that aren't visible.

Tree vs List vs Table

Use Tree when:

  • Data is hierarchical (files, org charts, nested categories)
  • Users need to expand/collapse sections
  • Depth varies per branch
  • Navigation follows parent-child relationships

Use List when:

  • Flat data with no hierarchy
  • Simple ordered collection
  • No expand/collapse needed

Use Table when:

  • Flat data with multiple columns
  • Comparison across attributes
  • Sorting is important

See Lists and Tables.

Keyboard navigation

Tree widgets support:

  • ↑/↓ - Navigate items (visible items only)
  • Enter - Activate (typically expand/collapse)
  • Tab - Focus next widget

Collapsed items are skipped during navigation - you only navigate visible items.

Real-world patterns

Project explorer:

TreeItem::new("my-project")
    .icon("📦")
    .expanded(true)
    .child(TreeItem::new("src").icon("📁").child(...))
    .child(TreeItem::new("tests").icon("📁").child(...))
    .child(TreeItem::new("Cargo.toml").icon("📄"))

Category navigation:

TreeItem::new("Products")
    .child(
        TreeItem::new("Electronics")
            .child(TreeItem::new("Phones"))
            .child(TreeItem::new("Laptops"))
    )
    .child(
        TreeItem::new("Clothing")
            .child(TreeItem::new("Shirts"))
            .child(TreeItem::new("Pants"))
    )

Organization chart:

TreeItem::new("CEO")
    .child(
        TreeItem::new("VP Engineering")
            .child(TreeItem::new("Team Lead - Backend"))
            .child(TreeItem::new("Team Lead - Frontend"))
    )
    .child(TreeItem::new("VP Sales"))

Tips

Path is zero-indexed - First root item is [0], first child of first item is [0, 0].

Store expanded paths - Keep a list of expanded paths in state. Check against this list when building the tree.

Icons are optional - Omit .icon() for plain text trees. Icons make it easier to distinguish file types.

Children are Vec - TreeItem has a public .children field. You can access it directly if needed.

Rebuild on expansion - When the user expands/collapses, rebuild the entire tree with updated .expanded() values.

Selection persists - Selected path stays valid even when you collapse ancestors. The widget handles this gracefully.

Empty children - A TreeItem with no children shows no expand/collapse indicator.

Custom icons - Use any Unicode: "►", "●", "[D]", or emojis work fine.

Next: Tabs

Tabs

Organize multiple screens into a tabbed interface with keyboard navigation.

let active = state!(cx, || 0);

View::tabs()
    .tab("Overview", overview_content)
    .tab("Settings", settings_content)
    .tab("About", about_content)
    .active(active.get())
    .on_change(with!(active => move |idx| active.set(idx)))
    .build()

Run with: cargo run -p telex-tui --example 14_tabs

Basic tabs

Tabs display one of several screens based on which tab is active:

View::tabs()
    .tab("Home", View::text("Welcome home!"))
    .tab("Profile", View::text("Your profile"))
    .tab("Settings", View::text("App settings"))
    .build()

The first tab ("Home") is active by default.

Controlling active tab

Track which tab is visible with state:

let active_tab = state!(cx, || 0);  // 0 = first tab

View::tabs()
    .tab("Tab 1", content1)
    .tab("Tab 2", content2)
    .tab("Tab 3", content3)
    .active(active_tab.get())
    .on_change(with!(active_tab => move |idx: usize| {
        active_tab.set(idx);
    }))
    .build()

When the user switches tabs, on_change fires with the new index.

Tab content

Each tab's content is a full View. You can put anything inside:

View::tabs()
    .tab("List", View::list().items(items).build())
    .tab("Table", View::table().rows(rows).build())
    .tab("Form",
        View::vstack()
            .child(View::text_input()...)
            .child(View::button()...)
            .build()
    )
    .active(active.get())
    .on_change(on_change)
    .build()

Keyboard navigation

Tabs support multiple navigation methods:

  • ←/→ - Previous/next tab
  • [ and ] - Previous/next tab (alternative)
  • 1, 2, 3, ... - Jump to specific tab by number

All methods wrap around (last → first, first → last).

Per-tab state

State persists even when tabs aren't visible:

let active_tab = state!(cx, || 0);

// These states persist across tab switches
let settings_notify = state!(cx, || true);
let settings_theme = state!(cx, || "dark".to_string());
let profile_name = state!(cx, || String::new());

View::tabs()
    .tab("Profile",
        View::text_input()
            .value(profile_name.get())
            .on_change(with!(profile_name => move |s| profile_name.set(s)))
            .build()
    )
    .tab("Settings",
        View::vstack()
            .child(View::checkbox()
                .label("Notifications")
                .checked(settings_notify.get())
                .on_toggle(with!(settings_notify => move |c| settings_notify.set(c)))
                .build())
            .build()
    )
    .active(active_tab.get())
    .on_change(with!(active_tab => move |idx| active_tab.set(idx)))
    .build()

When you switch to Settings and back to Profile, the input value is still there.

Dynamic tab content

Rebuild tab content based on state:

let data = state!(cx, Vec::new);

View::tabs()
    .tab("Data", View::list().items(data.get()).build())
    .tab("Add",
        View::button()
            .label("Add Item")
            .on_press(with!(data => move || {
                data.update(|v| v.push("New".into()));
            }))
            .build()
    )
    .active(active.get())
    .on_change(on_change)
    .build()

Adding an item in the "Add" tab updates the list shown in the "Data" tab.

Programmatic tab switching

Switch tabs from code:

// Switch to second tab
active_tab.set(1);

// Button that switches tabs
View::button()
    .label("Go to Settings")
    .on_press(with!(active_tab => move || active_tab.set(2)))
    .build()

Tab bar styling

Tab labels appear in a row at the top. The active tab is highlighted.

The widget handles all styling automatically - you just provide labels.

Number of tabs

You can have as many tabs as you want, but consider usability:

  • 2-5 tabs - Ideal, easy to navigate
  • 6-8 tabs - Acceptable, fits most terminals
  • 9+ tabs - Gets crowded, consider nested tabs or a different UI

Empty tabs

You can have an empty tab:

.tab("Coming Soon", View::styled_text("This feature is under development").dim().build())

Real-world example

Settings screen with multiple categories:

let active_tab = state!(cx, || 0);
let notifications = state!(cx, || true);
let dark_mode = state!(cx, || true);
let auto_save = state!(cx, || true);

View::tabs()
    .tab("Overview",
        View::vstack()
            .child(View::styled_text("Welcome!").bold().build())
            .child(View::text("Configure your app using the tabs above"))
            .build()
    )
    .tab("Settings",
        View::vstack()
            .child(View::checkbox()
                .label("Enable notifications")
                .checked(notifications.get())
                .on_toggle(with!(notifications => move |c| notifications.set(c)))
                .build())
            .child(View::checkbox()
                .label("Dark mode")
                .checked(dark_mode.get())
                .on_toggle(with!(dark_mode => move |c| dark_mode.set(c)))
                .build())
            .child(View::checkbox()
                .label("Auto-save")
                .checked(auto_save.get())
                .on_toggle(with!(auto_save => move |c| auto_save.set(c)))
                .build())
            .build()
    )
    .tab("About",
        View::vstack()
            .child(View::text("My App v1.0"))
            .child(View::text("Built with Telex"))
            .build()
    )
    .active(active_tab.get())
    .on_change(with!(active_tab => move |idx| active_tab.set(idx)))
    .build()

Tabs in modals

Tabs work inside modals:

View::modal()
    .visible(show_settings.get())
    .title("Settings")
    .child(
        View::tabs()
            .tab("General", general_settings)
            .tab("Advanced", advanced_settings)
            .active(settings_tab.get())
            .on_change(on_tab_change)
            .build()
    )
    .build()

Conditional tabs

Show different tabs based on state:

let is_admin = state!(cx, || false);

let mut tabs_view = View::tabs()
    .tab("Home", home_content)
    .tab("Profile", profile_content);

if is_admin.get() {
    tabs_view = tabs_view.tab("Admin", admin_content);
}

tabs_view
    .active(active.get())
    .on_change(on_change)
    .build()

Note: Be careful with conditional tabs and active index - ensure the index stays valid.

Tips

Active is zero-indexed - First tab is 0, second is 1, etc.

State persists - All tabs render simultaneously (only one is visible). State in inactive tabs is preserved.

Labels should be short - Keep tab labels to 10-15 characters. Long labels crowd the tab bar.

Don't nest tabs - Tabs within tabs is confusing. Use a different navigation pattern.

Consider order - Put the most important/frequently used tab first.

Number shortcuts - Users can press 1, 2, 3 to jump to tabs. Design with this in mind.

Wrap around - Left arrow on first tab goes to last tab. Right arrow on last tab goes to first.

Tab bar always visible - The tab labels stay at the top while the content scrolls.

Next: Forms

Forms

Declarative form validation with built-in validators and error handling.

let form = state!(cx, || {
    FormState::new()
        .field(
            FieldBuilder::new("email")
                .required()
                .email()
                .error_message("Please enter a valid email")
                .build()
        )
});

View::form_field("email")
    .label("Email Address")
    .value(form.get().get_value("email"))
    .error(form.get().get_error("email"))
    .on_change(with!(form => move |v: String| {
        form.get().set_value("email", v);
    }))
    .build()

Run with: cargo run -p telex-tui --example 22_forms

Form state

Create a FormState to manage all fields:

let form = state!(cx, || {
    FormState::new()
        .field(FieldBuilder::new("username").required().build())
        .field(FieldBuilder::new("email").required().email().build())
        .field(FieldBuilder::new("password").required().min_length(8).build())
});

Each field has a name, validators, and optional error messages.

Built-in validators

FieldBuilder provides common validators:

FieldBuilder::new("field_name")
    .required()                    // must not be empty
    .email()                       // must be valid email
    .min_length(n)                 // at least n characters
    .max_length(n)                 // at most n characters
    .integer()                     // must parse as integer
    .error_message("Custom error") // shown on validation failure
    .build()

Validators chain - all must pass for the field to be valid.

Custom validators

Add custom validation logic:

FieldBuilder::new("username")
    .custom(|value| {
        if value.contains(' ') {
            Some("Username cannot contain spaces".into())
        } else if !value.chars().all(|c| c.is_alphanumeric()) {
            Some("Only letters and numbers allowed".into())
        } else {
            None  // Valid
        }
    })
    .build()

Custom validators return Option<String>:

  • None - field is valid
  • Some(msg) - field is invalid, show this error

Form fields

Render fields with View::form_field():

View::form_field("email")
    .label("Email Address *")
    .value(form.get().get_value("email"))
    .placeholder("you@example.com")
    .error(form.get().get_error("email"))
    .on_change(with!(form => move |v: String| {
        form.get().set_value("email", v);
    }))
    .on_blur(with!(form => move || {
        form.get().touch("email");
    }))
    .build()

The form field widget handles:

  • Label display
  • Input rendering
  • Error message display (red text below input)
  • Placeholder text

Validation timing

Validation happens on two events:

On change: Update the value, but don't show errors yet

.on_change(with!(form => move |v| {
    form.get().set_value("field", v);
}))

On blur: Mark field as "touched" to show errors

.on_blur(with!(form => move || {
    form.get().touch("field");
}))

This prevents showing errors before the user finishes typing.

Submitting the form

Check validity before processing:

let on_submit = with!(form => move || {
    if form.get().validate() {
        // Form is valid - process it
        let values = form.get().values();
        let email = values.get("email").unwrap();
        let password = values.get("password").unwrap();

        save_user(email, password);
    } else {
        // Show error message
        show_error("Please fix the errors above");
    }
});

View::button()
    .label("Submit")
    .on_press(on_submit)
    .build()

.validate() returns true if all fields are valid.

Accessing values

Get individual field values:

let email = form.get().get_value("email");  // returns String

Get all values as a map:

let values = form.get().values();  // HashMap<String, String>
let email = values.get("email").unwrap_or(&String::new());

Accessing errors

Get field-specific errors:

let email_error = form.get().get_error("email");  // Option<String>

Check if the whole form is valid:

let is_valid = form.get().is_valid();  // bool

Password fields

For sensitive input like passwords, use the .password(true) method to hide the text as asterisks:

View::form_field("password")
    .label("Password")
    .value(password)
    .password(true)  // display as ****
    .error(error)
    .on_change(on_change)
    .build()

The actual value is still stored normally in state - only the display is masked.

Optional fields

Fields without .required() can be empty:

FieldBuilder::new("age")
    .integer()  // if not empty, must be an integer
    .custom(|v| {
        if v.is_empty() {
            return None;  // empty is ok
        }
        match v.parse::<i32>() {
            Ok(n) if n < 0 => Some("Age must be positive".into()),
            Ok(n) if n > 150 => Some("Please enter a realistic age".into()),
            Ok(_) => None,
            Err(_) => Some("Must be a number".into()),
        }
    })
    .build()

Resetting the form

Clear all values and errors:

let on_reset = with!(form => move || {
    form.get().reset();
});

View::button()
    .label("Reset")
    .on_press(on_reset)
    .build()

Form layout

Use View::form() to layout fields with consistent spacing:

View::form()
    .spacing(1)
    .child(email_field)
    .child(password_field)
    .child(username_field)
    .build()

This is just a vstack with spacing - purely cosmetic.

Real-world example

Registration form:

let form = state!(cx, || {
    FormState::new()
        .field(
            FieldBuilder::new("email")
                .required()
                .email()
                .error_message("Please enter a valid email address")
                .build()
        )
        .field(
            FieldBuilder::new("username")
                .required()
                .min_length(3)
                .max_length(20)
                .custom(|v| {
                    if !v.chars().all(|c| c.is_alphanumeric() || c == '_') {
                        Some("Only letters, numbers, and underscores".into())
                    } else {
                        None
                    }
                })
                .build()
        )
        .field(
            FieldBuilder::new("password")
                .required()
                .min_length(8)
                .custom(|v| {
                    if !v.chars().any(|c| c.is_numeric()) {
                        Some("Must contain at least one number".into())
                    } else {
                        None
                    }
                })
                .build()
        )
});

Validation messages

Show form-level validation status:

View::text(format!(
    "Form valid: {}",
    if form.get().is_valid() { "Yes" } else { "No" }
))
.color(if form.get().is_valid() { Color::Green } else { Color::Red })

Common patterns

Confirm password:

FieldBuilder::new("confirm_password")
    .custom({
        let password = password.clone();
        move |v| {
            if v != password.get() {
                Some("Passwords do not match".into())
            } else {
                None
            }
        }
    })
    .build()

Trim whitespace before validation:

.on_change(with!(form => move |v: String| {
    form.get().set_value("email", v.trim().to_string());
}))

Disable submit until valid:

View::button()
    .label("Submit")
    .disabled(!form.get().is_valid())
    .on_press(on_submit)
    .build()

Show error count:

let error_count = form.get().values().keys()
    .filter(|k| form.get().get_error(k).is_some())
    .count();

if error_count > 0 {
    View::text(format!("{} errors remaining", error_count))
}

Tips

Touch on blur - Only call .touch() when the user leaves a field. This prevents showing errors too early.

Required fields in labels - Mark required fields with * in the label: "Email Address *".

Custom error messages - Use .error_message() for friendlier errors than the defaults.

Validate on submit - Always call .validate() before processing, even if you track validity in the UI.

Field names are keys - Use consistent field names throughout (get_value, set_value, touch, get_error all use the same name).

Validators compose - Chain multiple validators: .required().email().min_length(5).

Custom validators are flexible - Access other state, make async checks (in the validator), whatever you need.

Reset doesn't re-validate - After .reset(), the form is back to initial state. Errors won't show until fields are touched again.

Next: Menus

Menus

Dropdown menu bars with keyboard shortcuts and command handling.

let file_menu = Menu::new("File")
    .command_with_shortcut("file.save", "Save", "Ctrl+S")
    .command_with_shortcut("file.quit", "Quit", "Ctrl+Q");

View::menu_bar()
    .menu(file_menu)
    .on_select(|cmd| {
        match cmd {
            "file.save" => save(),
            "file.quit" => quit(),
            _ => {}
        }
    })
    .build()

Run with: cargo run -p telex-tui --example 20_menu_bar

Limitations

Note: The current menu API doesn't support visually disabling menu items. You can handle disabled commands in your on_select handler (see Disabled items section below), but disabled items will still appear clickable in the menu.

Basic menu bar

Create a menu bar with dropdown menus:

let file_menu = Menu::new("File")
    .command("file.new", "New")
    .command("file.open", "Open")
    .command("file.quit", "Quit");

View::menu_bar()
    .menu(file_menu)
    .on_select(with!(state => move |cmd: &str| {
        handle_command(cmd, &state);
    }))
    .build()

Each menu has a label and a list of commands:

Menu::new("Edit")
    .command("edit.undo", "Undo")
    .command("edit.redo", "Redo")
    .separator()
    .command("edit.cut", "Cut")
    .command("edit.copy", "Copy")
    .command("edit.paste", "Paste")

.separator() adds a visual divider between groups of commands.

Command IDs

Commands have unique IDs (strings) that identify them when selected:

.command("namespace.action", "Display Label")

Use namespaced IDs like "file.save", "edit.copy" to avoid collisions.

Keyboard shortcuts

Show shortcuts next to menu items:

Menu::new("File")
    .command_with_shortcut("file.new", "New", "Ctrl+N")
    .command_with_shortcut("file.save", "Save", "Ctrl+S")
    .command_with_shortcut("file.quit", "Quit", "Ctrl+Q")

The shortcut is displayed but not automatically bound - you handle the actual keybinding separately.

Handling commands

Respond when users select menu items:

let on_select = with!(count => move |cmd: &str| {
    match cmd {
        "counter.increment" => count.update(|n| *n += 1),
        "counter.decrement" => count.update(|n| *n -= 1),
        "counter.reset" => count.set(0),
        _ => {}
    }
});

View::menu_bar()
    .menu(counter_menu)
    .on_select(on_select)
    .build()

Multiple menus

Add several menus to the bar:

View::menu_bar()
    .menu(file_menu)
    .menu(edit_menu)
    .menu(view_menu)
    .menu(help_menu)
    .on_select(on_select)
    .build()

Users navigate left/right between menus with arrow keys.

Keyboard navigation

Menu bars support full keyboard control:

  • Tab - Focus the menu bar
  • ←/→ - Switch between menus
  • Enter - Open the focused menu
  • ↑/↓ - Navigate items within an open menu
  • Enter - Execute the selected item
  • Escape - Close the menu

Track which menu and item are active:

let active_menu = state!(cx, || None::<usize>);
let highlighted_item = state!(cx, || 0usize);

View::menu_bar()
    .menu(file_menu)
    .menu(edit_menu)
    .active_menu(active_menu.get())
    .selected_item(highlighted_item.get())
    .on_menu_change(with!(active_menu => move |idx| {
        active_menu.set(idx);
    }))
    .on_item_change(with!(highlighted_item => move |idx| {
        highlighted_item.set(idx);
    }))
    .on_select(on_select)
    .build()

This lets you track and control menu state programmatically.

Implementing actual shortcuts

The menu bar shows shortcuts but doesn't bind them. Use use_command to implement the actual keys:

cx.use_command(
    KeyBinding::key(KeyCode::Char('s')).ctrl(true),
    with!(data => move || save_file(&data))
);

cx.use_command(
    KeyBinding::key(KeyCode::Char('q')).ctrl(true),
    move || std::process::exit(0)
);

Disabled items

You can't disable items directly in the current API, but you can handle disabled state in your command handler:

let on_select = with!(can_undo => move |cmd: &str| {
    match cmd {
        "edit.undo" if !can_undo.get() => {
            // Show "Nothing to undo" message
        }
        "edit.undo" => perform_undo(),
        _ => {}
    }
});

Real-world example

Application menu from Example 20:

let file_menu = Menu::new("File")
    .command_with_shortcut("file.new", "New", "Ctrl+N")
    .command_with_shortcut("file.open", "Open", "Ctrl+O")
    .command_with_shortcut("file.save", "Save", "Ctrl+S")
    .separator()
    .command_with_shortcut("file.quit", "Quit", "Ctrl+Q");

let edit_menu = Menu::new("Edit")
    .command_with_shortcut("edit.undo", "Undo", "Ctrl+Z")
    .command_with_shortcut("edit.redo", "Redo", "Ctrl+Y")
    .separator()
    .command_with_shortcut("edit.cut", "Cut", "Ctrl+X")
    .command_with_shortcut("edit.copy", "Copy", "Ctrl+C")
    .command_with_shortcut("edit.paste", "Paste", "Ctrl+V");

let on_select = move |cmd: &str| {
    match cmd {
        "file.new" => create_new_file(),
        "file.open" => open_file_dialog(),
        "file.save" => save_current_file(),
        "file.quit" => std::process::exit(0),
        "edit.undo" => undo_last_action(),
        "edit.redo" => redo_last_action(),
        "edit.cut" => cut_selection(),
        "edit.copy" => copy_selection(),
        "edit.paste" => paste_from_clipboard(),
        _ => {}
    }
};

View::menu_bar()
    .menu(file_menu)
    .menu(edit_menu)
    .on_select(on_select)
    .build()

Layout

The menu bar appears as the first row of your app. Put it at the top of a vstack:

View::vstack()
    .child(View::menu_bar().menu(...).build())
    .child(View::boxed().flex(1).child(main_content).build())
    .child(status_bar)
    .build()

Tips

Use namespaced IDs - Prefix commands with their menu: "file.save", "edit.undo".

Separate common groups - Use .separator() to group related commands (New/Open/Save, then Quit).

Show shortcuts - Even if users don't use them, shortcuts communicate what's possible.

Implement the shortcuts - Don't just display them - actually bind the keys with use_command.

Keep menus short - 3-7 items per menu is ideal. More than 10 gets unwieldy.

Standard order - File, Edit, View, Help is convention. Users expect this.

Command naming - Use verbs: "Save", "Copy", "Quit", not nouns.

Next: Toasts

Toasts

Ephemeral notifications that auto-dismiss after a duration.

let toasts = state!(cx, || ToastQueue::with_duration(Duration::from_secs(3)));

// Show toasts
toasts.get().success("Saved successfully!");
toasts.get().error("Connection failed");
toasts.get().info("Processing...");

// Render toast container
View::toast_container()
    .queue(toasts.get())
    .position(ToastPosition::BottomRight)
    .build()

Run with: cargo run -p telex-tui --example 21_toasts

Toast queue

Create a toast queue to manage notifications:

let toasts = state!(cx, || ToastQueue::with_duration(Duration::from_secs(3)));

The duration determines how long each toast stays visible before auto-dismissing.

Toast types

Show different toast types with color coding:

toasts.get().info("This is informational");
toasts.get().success("Operation completed!");
toasts.get().warning("Warning: check your input");
toasts.get().error("Error: something went wrong");

Each type has a distinct color:

  • Info - Blue/Cyan
  • Success - Green
  • Warning - Yellow
  • Error - Red

Positioning

Control where toasts appear on screen:

View::toast_container()
    .queue(toasts.get())
    .position(ToastPosition::BottomRight)  // default
    .build()

Available positions:

  • ToastPosition::TopRight
  • ToastPosition::TopLeft
  • ToastPosition::BottomLeft
  • ToastPosition::BottomRight

Auto-dismiss

Toasts automatically disappear after their duration:

// 3 second default
ToastQueue::with_duration(Duration::from_secs(3))

// Custom duration per toast
toasts.get().info_with_duration("Quick message", Duration::from_secs(1));
toasts.get().error_with_duration("Important error", Duration::from_secs(10));

Manual dismiss

Clear all toasts:

toasts.get().clear();

Clear a specific toast:

let id = toasts.get().info("Message");
toasts.get().remove(id);

Stacking

Multiple toasts stack vertically:

toasts.get().info("First");
toasts.get().info("Second");
toasts.get().info("Third");

New toasts appear below/above existing ones depending on position.

Real-world patterns

Save confirmation:

let on_save = with!(toasts, data => move || {
    match save_file(&data.get()) {
        Ok(_) => toasts.get().success("File saved successfully"),
        Err(e) => toasts.get().error(&format!("Save failed: {}", e)),
    }
});

Long-running operation:

let on_process = with!(toasts => move || {
    toasts.get().info("Processing...");

    std::thread::spawn(with!(toasts => move || {
        std::thread::sleep(Duration::from_secs(2));
        toasts.get().success("Processing complete!");
    }));
});

Form validation:

if form.get().is_valid() {
    submit_form();
    toasts.get().success("Form submitted!");
} else {
    toasts.get().error("Please fix the errors");
}

Tips

Don't overuse - Too many toasts becomes noise. Reserve for important events.

Match severity to type - Use error for actual errors, not "Could not find item" (use info for that).

Keep messages short - One line is ideal. Long messages get truncated.

Bottom right is standard - Users expect notifications in the bottom right corner.

Duration matters - 3-5 seconds for info/success, 5-10 seconds for warnings/errors.

Don't require action - Toasts are non-blocking. If the user needs to respond, use a modal.

Next: Canvas

Slider

A bounded numeric input controlled with arrow keys.

let volume = state!(cx, || 50.0);

View::slider()
    .min(0.0)
    .max(100.0)
    .step(1.0)
    .value(volume.get())
    .label("Volume")
    .on_change(with!(volume => move |v: f64| volume.set(v)))
    .build()

Run with: cargo run -p telex-tui --example 35_slider

Builder API

View::slider()
    .min(0.0)           // minimum value (default: 0.0)
    .max(100.0)         // maximum value (default: 100.0)
    .value(50.0)        // current value
    .step(1.0)          // increment per arrow key press (default: 1.0)
    .label("Volume")    // optional label shown alongside the slider
    .on_change(cb)      // callback receives new f64 value
    .build()

Interaction

  • Left arrow — Decrease by step
  • Right arrow — Increase by step
  • Values are clamped to the [min, max] range automatically

Examples

Percentage

let brightness = state!(cx, || 75.0);

View::slider()
    .min(0.0)
    .max(100.0)
    .step(5.0)
    .value(brightness.get())
    .label("Brightness")
    .on_change(with!(brightness => move |v: f64| brightness.set(v)))
    .build()

Fine-grained control

let opacity = state!(cx, || 1.0);

View::slider()
    .min(0.0)
    .max(1.0)
    .step(0.05)
    .value(opacity.get())
    .label("Opacity")
    .on_change(with!(opacity => move |v: f64| opacity.set(v)))
    .build()

Integer range

let font_size = state!(cx, || 14.0);

View::slider()
    .min(8.0)
    .max(32.0)
    .step(1.0)
    .value(font_size.get())
    .label("Font Size")
    .on_change(with!(font_size => move |v: f64| font_size.set(v)))
    .build()

Tips

Step size matters — A step of 1.0 with a range of 0-100 means 100 key presses to traverse. A step of 5.0 means 20. Choose a step that makes the slider usable.

Float values — The slider works with f64. If you need integers, round in your callback or when reading the value.

Label is optional — Omit .label() if the slider's purpose is clear from context.

Effects

Side effects that run after rendering, reacting to state changes or initialization.

// Run once on mount
effect_once!(cx, || {
    println!("Component mounted!");
    || {}  // cleanup function
});

// Run when dependency changes
effect!(cx, count.get(), |&val| {
    println!("Count changed to {}", val);
    || {}  // cleanup
});

Run with: cargo run -p telex-tui --example 32_effects

What are effects?

Effects let you run code in response to component lifecycle events or state changes. They're for side effects - things that aren't directly rendering:

  • Logging or analytics
  • Saving to local storage
  • Starting timers or intervals
  • Subscribing to external events
  • Updating window title
  • Sending metrics

The Macros

effect! — Run when dependencies change

effect!(cx, deps, |&d| {
    // effect body
    || {}  // cleanup
});

effect_once! — Run once on first render

effect_once!(cx, || {
    // initialization
    || {}  // cleanup
});

Both macros are order-independent — safe to use in conditionals, loops, or any order.

When to Use Effects

Use effects when:

  • Reacting to state changes (logging, saving, notifications)
  • One-time initialization (loading config, setting up)
  • Need cleanup logic (timers, subscriptions, resources)

Use streams instead when:

  • Continuous data source (polling, monitoring, tailing)
  • Data drives the UI (timers, live feeds, progress)
  • External events (files, system stats, network)

Use async instead when:

  • One-time data fetch with loading state
  • API calls, database queries, file loading

Effect timing

Effects run after the render completes. The rendering cycle:

  1. State changes
  2. render() is called, returns a View
  3. View is displayed to the screen
  4. Effects run

This ensures effects see the updated UI state and don't block rendering.

One-time effects

Use effect_once! for initialization that should happen exactly once:

let initialized = state!(cx, || false);

effect_once!(cx, with!(initialized => move || {
    // Runs only on first render
    println!("App started!");
    load_config();
    initialized.set(true);

    || {}  // cleanup (runs on unmount)
}));

Common uses:

  • Loading initial data
  • Setting up subscriptions
  • Logging app start
  • Initializing third-party libraries

Reactive effects

Use effect! to run code when a dependency changes:

let count = state!(cx, || 0);

effect!(cx, count.get(), with!(count => move |&value| {
    println!("Count is now: {}", value);
    save_count_to_disk(value);
    || {}  // cleanup
}));

The effect runs:

  • Once when the component first renders
  • Every time the dependency value changes

Dependencies

The second parameter to effect! is the dependency. When it changes (via PartialEq), the effect runs:

// Single dependency
effect!(cx, count.get(), |&c| {
    log_count(c);
    || {}
});

// String dependency
effect!(cx, name.get(), |n: &String| {
    save_name(n.clone());
    || {}
});

// Tuple for multiple dependencies
effect!(cx, (count.get(), name.get()), |(c, n)| {
    log_event(c, n);
    || {}
});

The effect closure receives a reference to the dependency value.

Cleanup functions

Effects return a cleanup closure that runs when:

  • The app exits
  • Before the effect runs again (if the dependency changed)
effect!(cx, interval_ms.get(), move |&ms| {
    let (tx, rx) = std::sync::mpsc::channel();

    // Start a timer
    let handle = std::thread::spawn(move || {
        loop {
            std::thread::sleep(Duration::from_millis(ms));
            if tx.send(()).is_err() {
                break;  // receiver dropped
            }
        }
    });

    // Cleanup: stop the timer
    move || {
        drop(rx);  // This will cause the thread to exit
        let _ = handle.join();
    }
});

If you don't need cleanup, return an empty closure: || {}.

Comparing effect types

TypeWhen it runsUse for
effect_once!Once on mountInitialization, setup
effect!When dependency changesReacting to state, saving data

Safe in conditionals

Unlike React hooks, the effect! and effect_once! macros are keyed by call site, not call order:

// SAFE: effect in conditional
if feature_enabled {
    effect!(cx, data.get(), |d| {
        log_feature_usage(d);
        || {}
    });
}

This works because each macro invocation gets a unique key based on its location in the source code.

Logging example

Track when state changes:

let count = state!(cx, || 0);

effect!(cx, count.get(), move |&c| {
    eprintln!("[{}] Count changed to {}", chrono::Utc::now(), c);
    || {}
});

Local storage example

Persist state to disk:

let todos = state!(cx, Vec::new);

effect!(cx, todos.get(), move |items| {
    let json = serde_json::to_string(items).unwrap();
    std::fs::write("todos.json", json).ok();
    || {}
});

Every time todos changes, the list is saved.

Timer example

Start a repeating timer:

let ticks = state!(cx, || 0);

effect_once!(cx, with!(ticks => move || {
    let (tx, rx) = std::sync::mpsc::channel();

    std::thread::spawn(move || {
        loop {
            std::thread::sleep(Duration::from_secs(1));
            if tx.send(()).is_err() { break; }
        }
    });

    std::thread::spawn(with!(ticks => move || {
        while rx.recv().is_ok() {
            ticks.update(|t| *t += 1);
        }
    }));

    move || drop(rx)  // cleanup stops both threads
}));

Note: For simpler cases, use stream! instead. Effects are better when you need cleanup logic.

Multiple effects

You can have multiple effects in one component:

// Initialize
effect_once!(cx, || {
    load_settings();
    || {}
});

// Save count
effect!(cx, count.get(), |&c| {
    save_count(c);
    || {}
});

// Save name
effect!(cx, name.get(), |n| {
    save_name(n);
    || {}
});

Each effect is independent. They don't interfere with each other.

Avoiding infinite loops

Don't update the same state you're watching:

// ❌ BAD: Infinite loop (will panic with cycle detection)
effect!(cx, count.get(), with!(count => move |&c| {
    count.set(c + 1);  // triggers effect again!
    || {}
}));

// ✅ GOOD: Update different state
effect!(cx, count.get(), with!(double => move |&c| {
    double.set(c * 2);  // updates different state
    || {}
}));

Telex includes automatic cycle detection — if an effect runs more than 100 times in 10 frames, it panics with a helpful error message.

Tips

Return cleanup even if empty - Always return || {} to match the expected signature.

Clone state for effects - Use with! to capture state handles in effect closures.

Effects don't block rendering - They run after the UI updates, so heavy work in effects won't freeze the UI.

One effect per concern - Don't cram multiple unrelated side effects into one effect. Separate them for clarity.

Dependencies must be values - Pass state.get(), not the State<T> handle itself. The effect watches the value, not the handle.

Cleanup runs before re-running - If your dependency changes rapidly, cleanup runs each time. Keep cleanup fast.

Effects can't return values - If you need computed values from state, do that during render, not in effects. Effects are for side effects only.

Debugging effects

Add logging to see when effects run:

effect!(cx, count.get(), |&c| {
    eprintln!("Effect running: count = {}", c);
    do_work(c);
    move || eprintln!("Cleanup running")
});

This helps track effect timing and dependencies.

Next: Async Data

Terminal Widget

⚠️ Experimental Preview Terminal widget is in early development with significant limitations. See below for details.

Interactive PTY terminal emulator for running shell commands and CLI applications within your TUI.

let terminal = cx.use_terminal();

// Spawn a shell on first render
if !terminal.is_started() {
    terminal.spawn("bash", &[], 80, 24)?;
}

View::terminal()
    .handle(terminal)
    .rows(24)
    .cols(80)
    .border(true)
    .title("Shell")
    .build()

Run with: cargo run -p telex-tui --example 33_terminal

What Works

  • ✅ PTY spawning with portable-pty
  • ✅ ANSI escape sequence parsing (colors, styles, cursor movement)
  • ✅ Full keyboard input (arrows, Ctrl+C, function keys, etc.)
  • ✅ Running interactive programs (bash, vim, htop, etc.)
  • ✅ Border with title
  • ✅ Focus management

Known Limitations

  • No scrollback buffer - Only visible area is stored
  • No terminal resize - Size is set at spawn time
  • No copy/paste
  • No mouse input
  • Exit callbacks not fully wired

Keyboard Shortcuts

  • Ctrl+Shift+[ - Escape terminal focus (return to TUI navigation)
  • Tab - Navigate to next widget
  • All other keys - Sent to the terminal PTY

Use Cases

// Shell access
terminal.spawn("bash", &[], 80, 24);

// Text editor
terminal.spawn("vim", &["config.toml"], 80, 24);

// System monitor
terminal.spawn("htop", &[], 80, 24);

// AI agent CLI
terminal.spawn("python", &["agent.py"], 80, 24);

Architecture

The terminal widget uses:

  • portable-pty - Cross-platform PTY spawning (Unix/Windows/macOS)
  • vte - ANSI escape sequence parser (industry standard, used by Alacritty)
  • Background threads - Non-blocking PTY I/O via mpsc channels
  • TerminalBuffer - 2D cell grid with cursor tracking

Recursive Telex

You can run Telex inside Telex:

# Inside a terminal widget:
cargo run -p telex-tui --example 33_terminal

This enables visual agent orchestration - multiple terminal widgets running AI agents with visible, interactive data flow.

Future Roadmap

Phase 2 (planned):

  • Scrollback buffer with configurable size
  • Terminal resize support
  • Copy/paste with mouse selection
  • Mouse input passthrough
  • Proper cleanup and exit code handling

Canvas

⚠️ Experimental Feature Canvas requires the Kitty graphics protocol and only works in compatible terminals (Kitty, Ghostty, WezTerm). Other terminals will show a placeholder message.

Pixel-level drawing using the Kitty graphics protocol for charts, graphs, and visualizations.

View::canvas()
    .width(200)
    .height(100)
    .on_draw(|ctx| {
        ctx.clear(Color::Black);
        ctx.line(0, 0, 200, 100, Color::Red);
        ctx.fill_rect(50, 50, 100, 40, Color::Blue);
        ctx.circle(150, 50, 20, Color::Green);
    })
    .build()

Run with: cargo run -p telex-tui --example 29_canvas

Terminal compatibility

⚠️ Important: Canvas requires terminals that support the Kitty graphics protocol. Check compatibility before using canvas widgets.

✅ Supported terminals:

  • Kitty
  • Ghostty
  • WezTerm (with Kitty protocol enabled)

❌ Not supported:

  • iTerm2 (uses different protocol)
  • Alacritty (no inline graphics)
  • Standard terminal.app

If your terminal doesn't support Kitty graphics, canvas widgets won't render. No fallback is provided - use a compatible terminal or stick to character-based visualizations.

Basic canvas

Create a canvas with width and height in pixels:

View::canvas()
    .width(400)  // pixels wide
    .height(300)  // pixels tall
    .on_draw(|ctx| {
        // Drawing code here
    })
    .build()

The on_draw closure receives a drawing context with primitive operations.

Drawing primitives

Clear

Fill the entire canvas with a color:

ctx.clear(Color::Rgb { r: 30, g: 30, b: 40 });

Lines

Draw lines between two points:

ctx.line(x1, y1, x2, y2, color);

// Example: horizontal line
ctx.line(0, 50, 400, 50, Color::Red);

// Example: diagonal
ctx.line(0, 0, 400, 300, Color::Blue);

Rectangles

Draw filled rectangles:

ctx.fill_rect(x, y, width, height, color);

// Example: red square at (100, 100)
ctx.fill_rect(100, 100, 50, 50, Color::Red);

Circles

Draw circles (filled):

ctx.circle(center_x, center_y, radius, color);

// Example: green circle
ctx.circle(200, 150, 30, Color::Green);

Coordinate system

  • Origin (0, 0) is top-left
  • X increases to the right
  • Y increases downward
  • Units are pixels

Colors

Use Color enum for drawing:

Color::Red
Color::Blue
Color::Green
Color::Cyan
Color::Magenta
Color::Yellow
Color::White
Color::Black
Color::Rgb { r: 100, g: 150, b: 200 }

Animation

Combine canvas with streams for animated graphics:

let frame = stream!(cx, || {
    (0u32..).map(|i| {
        std::thread::sleep(Duration::from_millis(50));
        i
    })
});

View::canvas()
    .width(200)
    .height(100)
    .on_draw({
        let current_frame = frame.get();
        move |ctx| {
            ctx.clear(Color::Black);

            // Animate position
            let x = (current_frame % 200) as i32;
            ctx.circle(x, 50, 10, Color::Red);
        }
    })
    .build()

Each frame triggers a re-render with updated position.

Bar charts

Visualize data with bar charts:

let data = vec![30, 50, 40, 80, 60];

View::canvas()
    .width(200)
    .height(100)
    .on_draw(move |ctx| {
        ctx.clear(Color::Black);

        let bar_width = 200 / data.len() as i32;

        for (i, &value) in data.iter().enumerate() {
            let x = i as i32 * bar_width;
            let height = value as i32;
            let y = 100 - height;

            ctx.fill_rect(x, y, bar_width - 2, height, Color::Cyan);
        }
    })
    .build()

Line graphs

Plot data points with lines:

let points = vec![(0, 50), (50, 30), (100, 70), (150, 40), (200, 60)];

View::canvas()
    .width(200)
    .height(100)
    .on_draw(move |ctx| {
        ctx.clear(Color::Black);

        for i in 0..points.len() - 1 {
            let (x1, y1) = points[i];
            let (x2, y2) = points[i + 1];
            ctx.line(x1, y1, x2, y2, Color::Green);
        }
    })
    .build()

Performance

Canvas operations are efficient - redrawing 60 times per second is feasible for simple graphics.

For complex scenes:

  • Limit drawing operations
  • Cache static elements
  • Use lower frame rates

Real-world uses

System monitoring:

let cpu = cpu_usage();  // 0-100

ctx.fill_rect(0, 0, cpu * 2, 20, Color::Green);

Progress visualization:

let progress = 0.7;  // 70%

let width = (400.0 * progress) as i32;
ctx.fill_rect(0, 0, width, 30, Color::Blue);

Sparklines:

let history = vec![10, 15, 13, 18, 22, 20, 25];

for (i, &v) in history.iter().enumerate() {
    let x = i as i32 * 5;
    let y = 30 - v;
    ctx.fill_rect(x, y, 4, v, Color::Cyan);
}

Tips

Check terminal compatibility - Test in Kitty/Ghostty/WezTerm. Canvas won't work in all terminals.

Pixel coordinates - Canvas uses pixel units, not character cells.

Clear first - Always call ctx.clear() at the start of your draw function to avoid artifacts.

Keep it simple - Complex graphics can be slow. Test performance if animating.

Alternative: ASCII art - For terminal-agnostic apps, use character-based visualizations instead of canvas.

Size matters - Larger canvases use more resources. Don't make canvases bigger than needed.

No text rendering - Canvas doesn't support text. Use regular TUI widgets for labels.

Next: Keyed State

Image Display

⚠️ Experimental Feature Image widget requires the Kitty graphics protocol and only works in compatible terminals (Kitty, Ghostty, WezTerm). Other terminals will show a placeholder message.

Display PNG, JPEG, and GIF images using the Kitty graphics protocol.

// From file
View::image()
    .file("logo.png")
    .width(40)   // cells
    .height(20)  // cells
    .build()

// From bytes
View::image()
    .data(include_bytes!("logo.png"))
    .alt("Company logo")
    .build()

Run with: cargo run -p telex-tui --example 30_image

Terminal Compatibility

Supported terminals:

  • Kitty
  • Ghostty
  • WezTerm

Unsupported terminals: Other terminals will display the alt text or a placeholder message: [Image: requires Kitty/Ghostty/WezTerm]

Features

  • Format support: PNG, JPEG, GIF
  • Animations: GIF animations handled natively by Kitty
  • Auto-sizing: Images auto-detect dimensions if not specified
  • Caching: Uses Kitty's image ID system for efficient re-renders

Image Sources

From File Path

View::image()
    .file("screenshots/demo.png")
    .build()

From Bytes

const LOGO: &[u8] = include_bytes!("../assets/logo.png");

View::image()
    .data(LOGO)
    .build()

With Sizing

View::image()
    .file("banner.png")
    .width(60)   // Force 60 columns wide
    .height(15)  // Force 15 rows tall
    .build()

Accessibility

Use .alt() to provide fallback text:

View::image()
    .file("chart.png")
    .alt("Sales chart showing 40% growth in Q4")
    .build()

Limitations

  • Requires Kitty graphics protocol
  • No image manipulation (resize, crop, filters)
  • No lazy loading
  • Images are re-encoded on every render (cached by Kitty)

How It Works

The image widget:

  1. Encodes image data as base64
  2. Generates Kitty graphics protocol escape sequences
  3. Writes sequences to terminal
  4. Kitty decodes and displays the image as pixel overlay
  5. Terminal cell buffer shows spaces (image overlays them)

Future Improvements

  • Fallback rendering for non-Kitty terminals (ASCII art, sixel)
  • Image caching to avoid re-encoding
  • Lazy loading for large images
  • Basic transformations (scale, crop)

Keyed State

Order-independent state with the state! macro.

// Safe inside conditionals
if show_counter {
    let count = state!(cx, || 0);  // unique key per call site
    count.update(|n| *n += 1);
}

Run with: cargo run -p telex-tui --example 27_keyed_state

The Hook Order Problem

React developers know this rule: hooks must be called in the same order every render. Index-based state has this restriction — if you call state hooks conditionally, indices shift between renders, causing type mismatches and panics.

Telex solves this with the state! macro, which uses key-based storage instead of index-based. Each state! call gets a unique compile-time key based on its source location, so order never matters.

The Solution: state!

The state! macro creates keyed state - each invocation gets a unique compile-time key based on its code location:

// ✅ CORRECT - This works!
fn render(&self, cx: Scope) -> View {
    if show_counter {
        let count = state!(cx, || 0);  // Key: unique to this line
    }
    let name = state!(cx, String::new);  // Key: unique to this line
}

Each state! call generates a unique zero-sized type as its key, so order doesn't matter.

How It Works

Under the hood, state! expands to use_state_keyed with an automatically generated key type:

// What you write:
let count = state!(cx, || 0);

// What the macro generates (simplified):
struct __Key_file_line_27_col_17;
let count = cx.use_state_keyed::<__Key_file_line_27_col_17, _>(|| 0);

The key type is unique to that exact location in your source code, so each state! call gets its own storage slot.

Basic Usage

let enabled = state!(cx, || true);

enabled.get()              // read
enabled.set(false)         // write
enabled.update(|b| *b = !*b)  // toggle

The API is identical to use_state_keyed - only the creation differs (automatic key vs explicit key).

Conditional State

The killer feature: state that exists only when needed:

let show_advanced = state!(cx, || false);

if show_advanced.get() {
    let detail_level = state!(cx, || 5);
    let custom_option = state!(cx, String::new);

    // Build advanced UI using these states
}

When show_advanced is false, the detail_level and custom_option states aren't created. When it becomes true, they're initialized. When it becomes false again, the values are preserved - turning it back on shows the same values.

Real-World Example

A settings panel with collapsible sections:

let show_notifications = state!(cx, || false);
let show_appearance = state!(cx, || false);
let show_advanced = state!(cx, || false);

View::vstack()
    .child(section_header("Notifications", show_notifications.clone()))
    .child(if show_notifications.get() {
        let email = state!(cx, || true);
        let push = state!(cx, || true);
        let sms = state!(cx, || false);

        View::vstack()
            .child(checkbox("Email notifications", email))
            .child(checkbox("Push notifications", push))
            .child(checkbox("SMS notifications", sms))
            .build()
    } else {
        View::empty()
    })
    .child(section_header("Appearance", show_appearance.clone()))
    .child(if show_appearance.get() {
        let theme = state!(cx, || "dark".to_string());
        let font_size = state!(cx, || 14);

        View::vstack()
            .child(theme_picker(theme))
            .child(font_size_slider(font_size))
            .build()
    } else {
        View::empty()
    })
    .build()

Each section's state only exists when expanded, but values persist across collapse/expand.

Dynamic Lists

Create state for dynamic collections:

let items = state!(cx, || vec![
    "Item 1".to_string(),
    "Item 2".to_string(),
]);

// Each item gets conditional state
for (i, item) in items.get().iter().enumerate() {
    if i == selected.get() {
        // State that only exists for the selected item
        let editing = state!(cx, || false);
        let draft = state!(cx, || item.clone());

        // Edit UI
    }
}

Important: This works because each state! call is at a unique source location. The loop body is the same location, so all iterations share the same key. For per-item state, see Shared State.

state! vs use_state_keyed

Use state! for almost everything — it handles ordering automatically and is safe in conditionals.

Use cx.use_state_keyed::<Key, _>(|| init) when:

  • You need shared state across multiple call sites (same key = same state)
  • You need explicit control over which state slot is used
// Shared state — same key type = same state everywhere
struct ThemeKey;
let theme = cx.use_state_keyed::<ThemeKey, _>(|| "dark".to_string());

Multiple Conditionals

You can nest conditionals freely:

if mode.get() == "advanced" {
    let option_a = state!(cx, || false);

    if option_a.get() {
        let sub_option = state!(cx, || "default");
        // Use sub_option
    }
}

Each state! has its own unique key, so they never interfere.

State Lifecycle

Creation: State is created the first time its state! call is executed.

Persistence: Once created, state persists even if the conditional becomes false. It's stored by key, not by execution path.

Cleanup: State is only cleaned up when the component unmounts.

This means:

if show_counter {
    let count = state!(cx, || 0);
    count.update(|n| *n += 1);
}

// Later, show_counter becomes false, then true again
// The count value is preserved - it didn't reset to 0

Common Patterns

Toggle with state:

let expanded = state!(cx, || false);
let toggle = with!(expanded => move || expanded.update(|b| *b = !*b));

View::button()
    .label(if expanded.get() { "Collapse" } else { "Expand" })
    .on_press(toggle)
    .build()

Conditional form:

if show_form.get() {
    let name = state!(cx, String::new);
    let email = state!(cx, String::new);

    View::vstack()
        .child(input_field("Name", name))
        .child(input_field("Email", email))
        .child(submit_button(name, email))
        .build()
}

Mode-based state:

match mode.get() {
    Mode::Edit => {
        let draft = state!(cx, || original.clone());
        edit_view(draft)
    }
    Mode::View => {
        view_display(original)
    }
    Mode::Preview => {
        let zoom = state!(cx, || 100);
        preview_view(original, zoom)
    }
}

Each mode gets its own isolated state.

Debugging

If you see "State type mismatch" with state!, you might have:

// Same location, different types on different renders
if condition_a {
    let x = state!(cx, || 0i32);  // Key_123 -> i32
}
if condition_b {
    let x = state!(cx, || "hello");  // Key_123 -> String (CONFLICT!)
}

Solution: Use different variable names or move them to different locations:

if condition_a {
    let num = state!(cx, || 0i32);
}
if condition_b {
    let text = state!(cx, || "hello");
}

Performance

state! uses O(1) hash map access for key lookup — completely negligible in UI code.

Limitations

state! keys are based on source location, not execution context:

// This doesn't work as you might expect
for i in 0..5 {
    let count = state!(cx, || 0);  // All iterations share the SAME state!
}

Each loop iteration is the same source location, so they get the same key. For per-item state, use explicit keys with cx.use_state_keyed (see Shared State).

Tips

Default to state! - Unless you need shared state across call sites, state! is the way to go.

Location matters - Moving state! to a different line creates a new state. Don't refactor carelessly.

Works with all types - Any Clone + 'static type works with state!.

Clone is cheap - State<T> is Rc<RefCell<T>> under the hood, so cloning the handle is cheap.

Next: Shared State

Shared State

Multiple components sharing the same state via explicit keys.

struct SharedCounterKey;

// Both get the SAME state
let count_a = cx.use_state_keyed::<SharedCounterKey, _>(|| 0);
let count_b = cx.use_state_keyed::<SharedCounterKey, _>(|| 0);

count_a.update(|n| *n += 1);  // Both see the update!

Run with: cargo run -p telex-tui --example 28_shared_state

The Concept

In Keyed State, we saw that state! creates unique keys for each call site, giving you independent state. Shared state is the opposite: same key = same state.

By using use_state_keyed with an explicit key type, multiple parts of your code can access the same underlying value.

Defining a Key

Create a zero-sized type to use as a key:

struct MySharedKey;

That's it. The type name becomes the key. Any code that uses MySharedKey will share state.

Using the Key

Access shared state with use_state_keyed:

struct CounterKey;

fn render(&self, cx: Scope) -> View {
    // First access - creates the state
    let count = cx.use_state_keyed::<CounterKey, _>(|| 0);

    // Later, elsewhere - gets the SAME state
    let count2 = cx.use_state_keyed::<CounterKey, _>(|| 0);

    // count and count2 point to the same value
    count.get() == count2.get()  // always true
}

The initializer || 0 only runs once, when the key is first encountered. Subsequent calls retrieve the existing state.

Multiple Components Sharing State

Different parts of your UI can share state:

struct SelectedTabKey;

fn tab_bar(cx: Scope) -> View {
    let selected = cx.use_state_keyed::<SelectedTabKey, _>(|| 0);

    // Render tab buttons that modify selected
}

fn tab_content(cx: Scope) -> View {
    let selected = cx.use_state_keyed::<SelectedTabKey, _>(|| 0);

    // Render content based on selected tab
}

// Both functions access the same selected tab index

When the tab bar changes the selection, the content updates automatically.

Shared State Across Renders

State persists across renders, so all access points see the same value:

struct AppModeKey;

fn render(&self, cx: Scope) -> View {
    let mode = cx.use_state_keyed::<AppModeKey, _>(|| Mode::View);

    View::vstack()
        .child(toolbar(cx))       // reads mode
        .child(main_content(cx))  // reads mode
        .child(status_bar(cx))    // reads mode
        .build()
}

fn toolbar(cx: Scope) -> View {
    let mode = cx.use_state_keyed::<AppModeKey, _>(|| Mode::View);
    // mode reflects current app mode set anywhere else
}

Real-World Example: Theme Switcher

A global theme setting:

struct ThemeKey;

fn app(cx: Scope) -> View {
    View::vstack()
        .child(header(cx))
        .child(content(cx))
        .child(settings_panel(cx))
        .build()
}

fn header(cx: Scope) -> View {
    let theme = cx.use_state_keyed::<ThemeKey, _>(|| "dark");

    let bg_color = match theme.get().as_str() {
        "dark" => Color::DarkGrey,
        "light" => Color::White,
        _ => Color::Blue,
    };

    View::boxed()
        .background(bg_color)
        .child(View::text("Header"))
        .build()
}

fn settings_panel(cx: Scope) -> View {
    let theme = cx.use_state_keyed::<ThemeKey, _>(|| "dark");

    View::button()
        .label("Toggle Theme")
        .on_press(with!(theme => move || {
            theme.update(|t| {
                *t = if *t == "dark" { "light".to_string() } else { "dark".to_string() }
            });
        }))
        .build()
}

Clicking the button in settings updates the theme everywhere.

Multiple Shared States

You can have as many shared states as you need:

struct UserNameKey;
struct UserEmailKey;
struct UserAvatarKey;

fn profile_editor(cx: Scope) -> View {
    let name = cx.use_state_keyed::<UserNameKey, _>(String::new);
    let email = cx.use_state_keyed::<UserEmailKey, _>(String::new);
    let avatar = cx.use_state_keyed::<UserAvatarKey, _>(|| "default.png");

    // Edit UI
}

fn profile_display(cx: Scope) -> View {
    let name = cx.use_state_keyed::<UserNameKey, _>(String::new);
    let email = cx.use_state_keyed::<UserEmailKey, _>(String::new);
    let avatar = cx.use_state_keyed::<UserAvatarKey, _>(|| "default.png");

    // Display UI - sees changes from editor
}

Each key is independent - changing name doesn't affect email.

Shared State in Loops

Shared keys work inside loops:

struct ListSelectionKey;

for item in items.get() {
    let selected = cx.use_state_keyed::<ListSelectionKey, _>(|| 0);

    // All iterations see the same selection
    let is_selected = selected.get() == item.id;
}

This is useful for coordinating behavior across list items.

When to Use Shared State

Use shared state when:

  • Multiple UI components need the same data
  • You want global app-level state (theme, user session, etc.)
  • Avoiding prop drilling through many layers
  • Coordinating state across distant components

Use regular state when:

  • State is local to one component
  • You want isolation and independence
  • State doesn't need to be accessed elsewhere

Shared State vs Context

Both solve similar problems - sharing data across components. The difference:

Shared State (use_state_keyed):

  • Explicit key types
  • Compile-time enforcement
  • State is mutable via State<T> API
  • Good for frequently changing data

Context (use_context):

  • Provider/consumer pattern
  • Runtime lookup (returns Option<T>)
  • Data is typically immutable
  • Good for configuration and DI

See Context for details.

Type Safety

Keys are type-safe. Different keys can't accidentally collide:

struct CountKey;
struct NameKey;

let count = cx.use_state_keyed::<CountKey, _>(|| 0i32);
let name = cx.use_state_keyed::<NameKey, _>(String::new);

// These are completely independent
// No runtime conflicts possible

Initialization

The initializer runs only once, when the key is first used:

struct ExpensiveKey;

let data = cx.use_state_keyed::<ExpensiveKey, _>(|| {
    println!("Initializing!");  // Prints once
    load_from_disk()
});

let data2 = cx.use_state_keyed::<ExpensiveKey, _>(|| {
    println!("Initializing!");  // Never prints
    load_from_disk()             // Never runs
});

Sharing with Explicit Keys

You can parameterize keys for fine-grained sharing:

// Instead of a single shared counter:
struct CounterKey;

// Use multiple keys for independent counters:
struct CounterAKey;
struct CounterBKey;
struct CounterCKey;

let a = cx.use_state_keyed::<CounterAKey, _>(|| 0);
let b = cx.use_state_keyed::<CounterBKey, _>(|| 0);
let c = cx.use_state_keyed::<CounterCKey, _>(|| 0);

Or use a generic key with phantom data:

use std::marker::PhantomData;

struct CounterKey<T>(PhantomData<T>);

struct A;
struct B;

let counter_a = cx.use_state_keyed::<CounterKey<A>, _>(|| 0);
let counter_b = cx.use_state_keyed::<CounterKey<B>, _>(|| 0);

Common Patterns

Global loading state:

struct LoadingKey;

fn anywhere(cx: Scope) {
    let loading = cx.use_state_keyed::<LoadingKey, _>(|| false);
    loading.set(true);  // Shows loading spinner globally
}

Sidebar open/closed:

struct SidebarOpenKey;

fn sidebar_toggle(cx: Scope) -> View {
    let open = cx.use_state_keyed::<SidebarOpenKey, _>(|| true);

    View::button()
        .label(if open.get() { "Close" } else { "Open" })
        .on_press(with!(open => move || open.update(|b| *b = !*b)))
        .build()
}

fn sidebar(cx: Scope) -> View {
    let open = cx.use_state_keyed::<SidebarOpenKey, _>(|| true);

    if !open.get() {
        return View::empty();
    }

    // Sidebar content
}

Selected item ID:

struct SelectedItemKey;

fn item_list(cx: Scope, items: Vec<Item>) -> View {
    let selected = cx.use_state_keyed::<SelectedItemKey, _>(|| None::<usize>);

    View::list()
        .items(items.iter().map(|i| i.name.clone()).collect())
        .selected(selected.get().unwrap_or(0))
        .on_select(with!(selected => move |idx| selected.set(Some(idx))))
        .build()
}

fn item_details(cx: Scope, items: Vec<Item>) -> View {
    let selected = cx.use_state_keyed::<SelectedItemKey, _>(|| None::<usize>);

    match selected.get() {
        Some(idx) => View::text(&items[idx].description),
        None => View::text("No item selected"),
    }
}

Debugging

If you see unexpected sharing, check that your key types are distinct:

// BAD - Same key name in different modules
mod panel_a {
    struct SelectedKey;
}

mod panel_b {
    struct SelectedKey;  // Different type, but if imported wrong...
}

// GOOD - Unique names
struct PanelASelectedKey;
struct PanelBSelectedKey;

Performance

Shared state has the same performance as regular state:

  • O(1) key lookup (hash map)
  • O(1) read/write via State<T>

The only difference is the key is provided explicitly instead of generated.

Limitations

No scoping: Shared state is global to the component tree. You can't have "scoped" shared state that only exists in part of the tree.

No hierarchies: Keys are flat - you can't nest or inherit keys.

Manual coordination: You're responsible for ensuring the right components use the right keys.

For scoped sharing, use Context instead.

Tips

Name keys clearly - struct ThemeKey is better than struct Key1.

One key per concept - Don't reuse keys for unrelated data.

Document key purpose - Add comments explaining what each key is for:

/// Shared state for currently selected tab index
struct SelectedTabKey;

Prefer context for config - Use shared state for mutable data, context for immutable config.

Group related keys - Put keys in a module:

mod app_state {
    pub struct ThemeKey;
    pub struct UserKey;
    pub struct LoadingKey;
}

Next: Context

Context

Share data across components without prop drilling.

// Provide (parent)
cx.provide_context(AppConfig {
    theme: "dark",
    api_url: "https://api.example.com",
});

// Consume (anywhere below)
let config = cx.use_context::<AppConfig>();

Run with: cargo run -p telex-tui --example 25_context

The Problem: Prop Drilling

Passing data through many layers is tedious:

fn app() -> View {
    let theme = "dark";
    toolbar(theme)  // pass it down
}

fn toolbar(theme: &str) -> View {
    menu(theme)  // pass it down again
}

fn menu(theme: &str) -> View {
    menu_item(theme)  // and again...
}

fn menu_item(theme: &str) -> View {
    // Finally use it!
}

This is prop drilling - threading data through components that don't need it, just to get it deeper.

The Solution: Context

Context lets you provide data at the top and consume it anywhere below:

fn app(cx: Scope) -> View {
    cx.provide_context("dark");

    toolbar(&cx)  // no props needed!
}

fn menu_item(cx: &Scope) -> View {
    let theme = cx.use_context::<&str>();
    // Use it directly!
}

No intermediate functions need to know about the theme.

Providing Context

Use provide_context to make data available to descendants:

#[derive(Clone)]
struct Config {
    api_url: String,
    timeout: u64,
}

impl Component for App {
    fn render(&self, cx: Scope) -> View {
        cx.provide_context(Config {
            api_url: "https://api.example.com".to_string(),
            timeout: 30,
        });

        // All descendants can access Config
        View::vstack()
            .child(header(&cx))
            .child(content(&cx))
            .build()
    }
}

The data must implement Clone + 'static.

Consuming Context

Use use_context to retrieve data from ancestors:

fn header(cx: &Scope) -> View {
    let config = cx.use_context::<Config>();

    match config {
        Some(cfg) => View::text(&cfg.api_url),
        None => View::text("No config"),
    }
}

use_context returns Option<T>:

  • Some(data) if an ancestor provided it
  • None if no provider exists

Multiple Context Types

You can provide multiple types:

#[derive(Clone)]
struct Theme {
    primary: Color,
    accent: Color,
}

#[derive(Clone)]
struct User {
    name: String,
    role: String,
}

impl Component for App {
    fn render(&self, cx: Scope) -> View {
        cx.provide_context(Theme {
            primary: Color::Blue,
            accent: Color::Cyan,
        });

        cx.provide_context(User {
            name: "Alice".to_string(),
            role: "Admin".to_string(),
        });

        // Both available to descendants
    }
}

fn sidebar(cx: &Scope) -> View {
    let theme = cx.use_context::<Theme>();
    let user = cx.use_context::<User>();

    // Use both
}

Each type is stored separately by TypeId.

Real-World Example: Theme System

A complete theme implementation:

#[derive(Clone, Copy)]
enum AppTheme {
    Light,
    Dark,
    HighContrast,
}

impl AppTheme {
    fn background(&self) -> Color {
        match self {
            AppTheme::Light => Color::White,
            AppTheme::Dark => Color::Black,
            AppTheme::HighContrast => Color::Black,
        }
    }

    fn text(&self) -> Color {
        match self {
            AppTheme::Light => Color::Black,
            AppTheme::Dark => Color::White,
            AppTheme::HighContrast => Color::Yellow,
        }
    }
}

impl Component for App {
    fn render(&self, cx: Scope) -> View {
        let theme = state!(cx, || AppTheme::Dark);

        // Provide current theme
        cx.provide_context(theme.get());

        View::vstack()
            .child(theme_picker(&cx, theme))
            .child(content(&cx))
            .build()
    }
}

fn content(cx: &Scope) -> View {
    let theme = cx.use_context::<AppTheme>()
        .unwrap_or(AppTheme::Dark);

    View::boxed()
        .background(theme.background())
        .child(
            View::styled_text("Content")
                .color(theme.text())
                .build()
        )
        .build()
}

Change the theme state, and all consumers update automatically.

Dynamic Context

Context can change over time by providing state values:

impl Component for App {
    fn render(&self, cx: Scope) -> View {
        let user = state!(cx, || User::guest());

        // Provide current user (clones the value)
        cx.provide_context(user.get());

        // When user.set() is called, next render provides new value
    }
}

Each render provides a fresh snapshot of the state.

Nested Providers

Child providers override parent providers for the same type:

fn app(cx: Scope) -> View {
    cx.provide_context("dark");  // App-level theme

    View::vstack()
        .child(sidebar(&cx))     // uses "dark"
        .child(special_panel(&cx))  // overrides to "light"
        .build()
}

fn special_panel(cx: &Scope) -> View {
    cx.provide_context("light");  // Override for this subtree

    // All descendants see "light", not "dark"
    panel_content(cx)
}

The innermost provider wins.

Context vs Shared State

Both solve similar problems but have different trade-offs:

Context (provide_context / use_context):

  • ✅ Provider/consumer pattern (explicit hierarchy)
  • ✅ Scoped to subtree (can override)
  • ✅ Returns Option<T> (safe if missing)
  • ✅ Good for configuration, theming, user sessions
  • ❌ Runtime lookup (slight overhead)
  • ❌ Immutable snapshots (provide fresh values each render)

Shared State (use_state_keyed):

  • ✅ Compile-time keys (type-safe)
  • ✅ Mutable via State<T> API
  • ✅ Good for frequently changing data
  • ❌ Global scope (no override)
  • ❌ Must use same key everywhere
  • ❌ No hierarchy

When to use which:

  • Context: Themes, user info, app config, dependency injection
  • Shared State: Global counters, selected items, UI state

See Shared State for more on use_state_keyed.

Common Patterns

App configuration:

#[derive(Clone)]
struct AppConfig {
    name: String,
    version: String,
    api_base: String,
    features: Vec<String>,
}

// Provide once at app root
cx.provide_context(AppConfig {
    name: "My App".to_string(),
    version: "1.0.0".to_string(),
    api_base: "https://api.example.com".to_string(),
    features: vec!["dark_mode".to_string(), "exports".to_string()],
});

// Use anywhere
fn about_dialog(cx: &Scope) -> View {
    let config = cx.use_context::<AppConfig>().unwrap();
    View::text(format!("{} v{}", config.name, config.version))
}

User session:

#[derive(Clone)]
struct Session {
    user_id: Option<u64>,
    token: Option<String>,
    permissions: Vec<String>,
}

impl Session {
    fn is_authenticated(&self) -> bool {
        self.user_id.is_some()
    }

    fn can(&self, permission: &str) -> bool {
        self.permissions.contains(&permission.to_string())
    }
}

// Provide session
let session = state!(cx, || Session::default());
cx.provide_context(session.get());

// Check permissions anywhere
fn admin_panel(cx: &Scope) -> View {
    let session = cx.use_context::<Session>();

    if !session.map(|s| s.can("admin")).unwrap_or(false) {
        return View::text("Access denied");
    }

    // Admin UI
}

Feature flags:

#[derive(Clone)]
struct Features {
    new_ui: bool,
    beta_features: bool,
    debug_mode: bool,
}

// Provide
cx.provide_context(Features {
    new_ui: true,
    beta_features: false,
    debug_mode: cfg!(debug_assertions),
});

// Use
fn experimental_widget(cx: &Scope) -> View {
    let features = cx.use_context::<Features>();

    if !features.map(|f| f.beta_features).unwrap_or(false) {
        return View::empty();
    }

    // Beta UI
}

Default Values

Handle missing context with defaults:

fn my_component(cx: &Scope) -> View {
    let theme = cx.use_context::<Theme>()
        .unwrap_or(Theme::default());

    // Always has a theme, even if not provided
}

Or use a helper function:

fn get_theme(cx: &Scope) -> Theme {
    cx.use_context::<Theme>()
        .unwrap_or(Theme::Dark)
}

Type Safety

Context is type-safe - you can't accidentally get the wrong type:

#[derive(Clone)]
struct UserName(String);

#[derive(Clone)]
struct AppName(String);

cx.provide_context(UserName("Alice".to_string()));

// This gets None (different type)
let app_name = cx.use_context::<AppName>();

Newtype wrappers give you distinct context slots.

Dependency Injection

Use context for dependency injection:

trait Database: Clone {
    fn query(&self, sql: &str) -> Vec<Row>;
}

#[derive(Clone)]
struct PostgresDB { /* ... */ }

impl Database for PostgresDB {
    fn query(&self, sql: &str) -> Vec<Row> {
        // Implementation
    }
}

// Provide
cx.provide_context(PostgresDB::new());

// Inject
fn data_table<D: Database + 'static>(cx: &Scope) -> View {
    let db = cx.use_context::<D>().expect("Database not provided");

    let rows = db.query("SELECT * FROM users");
    // Render rows
}

Swap implementations for testing or different environments.

Debugging

If use_context returns None:

  1. Check that an ancestor calls provide_context for that type
  2. Verify the type matches exactly (including generics)
  3. Ensure the provider is rendered before the consumer
  4. Check that context is provided in the same scope or an ancestor

Add debug output:

let config = cx.use_context::<Config>();
if config.is_none() {
    eprintln!("Warning: Config context not found!");
}

Performance

Context has minimal overhead:

  • Providing: O(1) insertion into hash map
  • Consuming: O(1) hash map lookup

The clone on provide is the main cost - keep context types lightweight or use Rc for large data:

use std::rc::Rc;

#[derive(Clone)]
struct LargeConfig(Rc<ExpensiveData>);

// Clone is cheap (just increments ref count)
cx.provide_context(LargeConfig(data));

Limitations

No reactivity: Context values are snapshots. Changing the original doesn't update consumers until the next render when you call provide_context again.

let config = state!(cx, || Config::default());

// Update config
config.update(|c| c.timeout = 60);

// Must re-provide for descendants to see new value
cx.provide_context(config.get());

This is why dynamic context works by providing state.get() - each render provides a fresh snapshot.

No subscriptions: Consumers don't subscribe to updates. They read during render. If context changes, the component must re-render for them to see it.

Tips

Provide early - Call provide_context near the top of your render function, before returning the view tree.

Small types - Keep context types small. Large data should be in Rc or Arc.

Newtype for clarity - Wrap primitives: struct UserId(u64) instead of bare u64.

Document requirements - If a component needs context, document which types it expects.

Provide defaults - Always unwrap with a sensible default: .unwrap_or_default().

One provider per tree - Don't provide the same type multiple times in a single component. Override in children if needed.

Clone is mandatory - Context types must be Clone. If your type can't clone, wrap it in Rc.

Comparison Table

FeatureContextShared StateProps
ScopeSubtreeGlobalDirect child
MutabilityImmutable snapshotsMutableImmutable
Type safetyRuntime (Option<T>)Compile-timeCompile-time
OverrideYes (nested providers)NoYes (pass different value)
Best forConfig, theme, sessionUI state, selectionsLocal data

Next: Troubleshooting

Reducer

Action-dispatched state management for complex state transitions.

let (state, dispatch) = reducer!(cx, AppState::Idle, |state, action| {
    match (state, action) {
        (_, Action::Reset) => AppState::Idle,
        (AppState::Idle, Action::Start) => AppState::Running,
        (AppState::Running, Action::Finish(data)) => AppState::Done(data),
        (s, _) => s,
    }
});

Run with: cargo run -p telex-tui --example 36_reducer

What is reducer!?

reducer! creates a state/dispatch pair — a pattern familiar from React's useReducer or Redux. Instead of mutating state directly, you dispatch actions that a reducer function processes into new state.

let (state, dispatch) = reducer!(cx, initial_state, |state, action| {
    // Return new state based on current state + action
    new_state
});

Returns:

  • state — A State<S> handle (same as state! returns)
  • dispatch — An Rc<dyn Fn(A)> that accepts actions

When to use reducer vs state!

Use reducer! when:

  • State transitions depend on the current state (state machines)
  • Multiple actions affect the same state in different ways
  • You want to centralize state logic in one place

Use state! when:

  • Simple values (counters, toggles, strings)
  • Transitions don't depend on current state
  • Direct .set() and .update() are clear enough

Example: form submission

#[derive(Clone)]
enum FormState {
    Editing,
    Submitting,
    Success(String),
    Error(String),
}

enum FormAction {
    Submit,
    Succeed(String),
    Fail(String),
    Reset,
}

let (form, dispatch) = reducer!(cx, FormState::Editing, |state, action| {
    match (state, action) {
        (FormState::Editing, FormAction::Submit) => FormState::Submitting,
        (FormState::Submitting, FormAction::Succeed(msg)) => FormState::Success(msg),
        (FormState::Submitting, FormAction::Fail(err)) => FormState::Error(err),
        (_, FormAction::Reset) => FormState::Editing,
        (s, _) => s,  // ignore invalid transitions
    }
});

// In callbacks
let on_submit = {
    let dispatch = dispatch.clone();
    Rc::new(move || dispatch(FormAction::Submit))
};

Dispatching from effects

effect!(cx, form.get(), {
    let dispatch = dispatch.clone();
    move |state| {
        if let FormState::Submitting = state {
            let dispatch = dispatch.clone();
            std::thread::spawn(move || {
                match submit_form() {
                    Ok(msg) => dispatch(FormAction::Succeed(msg)),
                    Err(e) => dispatch(FormAction::Fail(e.to_string())),
                }
            });
        }
        || {}
    }
});

Tips

State must be Clone — The reducer function takes S by value and returns S. Your state type needs Clone.

Actions can carry data — Use enum variants with fields to pass data with actions: Action::Loaded(String).

Dispatch is Rc — The dispatch function is wrapped in Rc, so it can be cloned into callbacks and closures.

Order-independent — Like all macros, reducer! is keyed by call site. Safe in conditionals.

Error Boundaries

Catch panics in child views and render a fallback instead of crashing.

View::error_boundary()
    .child(risky_view)
    .fallback(View::text("Something went wrong"))
    .build()

Run with: cargo run -p telex-tui --example 37_error_boundary

What are error boundaries?

Error boundaries wrap a child view tree and catch any panics that occur during rendering. Instead of crashing the entire application, the boundary renders a fallback view.

This is useful for:

  • Isolating untrusted or experimental UI sections
  • Graceful degradation when data is unexpected
  • Preventing one broken widget from taking down the whole app

Builder API

View::error_boundary()
    .child(view)        // the view that might panic
    .fallback(view)     // shown if child panics
    .build()

If no fallback is provided, the default is View::text("[error boundary: child panicked]").

Example: safe data display

let data = state!(cx, || None::<UserProfile>);

View::error_boundary()
    .child({
        let profile = data.get().unwrap();  // might panic if None
        View::text(format!("Welcome, {}!", profile.name))
    })
    .fallback(View::text("Profile unavailable"))
    .build()

Example: isolating widgets

View::vstack()
    .child(View::text("Header — always visible"))
    .child(
        View::error_boundary()
            .child(possibly_broken_widget())
            .fallback(
                View::styled_text("Widget failed to render")
                    .color(Color::Red)
                    .build()
            )
            .build()
    )
    .child(View::text("Footer — always visible"))
    .build()

What gets caught

Error boundaries catch panics during child view rendering. This includes:

  • unwrap() on None or Err
  • panic!() calls
  • Index out of bounds
  • Any other panic in the child view tree

Error boundaries do not catch:

  • Panics in effect cleanup functions
  • Panics in other threads (those abort the process)
  • Logic errors that don't panic

Tips

Keep fallbacks simple — The fallback should be a static view that can't itself panic.

Nest boundaries — You can nest error boundaries to isolate different parts of your UI independently.

Don't overuse — Error boundaries are for genuinely risky rendering. If you can validate data before rendering, prefer that.

Custom Widgets

Escape hatch for user-defined character-cell rendering via the Widget trait.

use telex::widget::Widget;
use telex::buffer::{Buffer, Rect};

struct Sparkline {
    data: Vec<u8>,
}

impl Widget for Sparkline {
    fn render(&self, area: Rect, buf: &mut Buffer) {
        let bars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
        for (i, &val) in self.data.iter().enumerate() {
            if i as u16 >= area.width { break; }
            let idx = (val as usize * 7) / 100;
            buf.set(area.x + i as u16, area.y, bars[idx],
                crossterm::style::Color::Cyan, crossterm::style::Color::Reset);
        }
    }
}

View::custom(Rc::new(RefCell::new(sparkline)))

Run with: cargo run -p telex-tui --example 38_custom_widget

The Widget trait

pub trait Widget {
    /// Draw into the buffer within the given area
    fn render(&self, area: Rect, buf: &mut Buffer);

    /// Whether this widget can receive keyboard focus (default: false)
    fn focusable(&self) -> bool { false }

    /// Preferred height given available width (default: None)
    fn height_hint(&self, _width: u16) -> Option<u16> { None }

    /// Preferred width (default: None)
    fn width_hint(&self) -> Option<u16> { None }
}

render

The only required method. Called each frame with:

  • area — A Rect { x, y, width, height } defining the available space
  • buf — A Buffer to write characters into

Use buf.set(x, y, char, fg, bg) to place characters. Stay within the area bounds.

focusable

Return true if the widget should participate in tab navigation.

height_hint / width_hint

Return Some(n) to suggest a preferred size. The layout engine uses these as hints but may allocate different dimensions.

Embedding in your UI

Wrap your widget in Rc<RefCell<_>> and pass to View::custom():

use std::rc::Rc;
use std::cell::RefCell;

let widget = Sparkline { data: vec![10, 50, 80, 30, 60] };
View::custom(Rc::new(RefCell::new(widget)))

Custom widgets compose with all other views — put them in stacks, boxes, tabs, or anywhere a View is accepted.

Example: spectrum visualizer

struct Spectrum {
    levels: Vec<f64>,
}

impl Widget for Spectrum {
    fn render(&self, area: Rect, buf: &mut Buffer) {
        for (i, &level) in self.levels.iter().enumerate() {
            if i as u16 >= area.width { break; }

            let height = (level * area.height as f64) as u16;
            for row in 0..area.height {
                let y = area.y + area.height - 1 - row;
                let ch = if row < height { '█' } else { ' ' };
                buf.set(area.x + i as u16, y, ch,
                    crossterm::style::Color::Green,
                    crossterm::style::Color::Reset);
            }
        }
    }

    fn height_hint(&self, _width: u16) -> Option<u16> {
        Some(10)
    }

    fn width_hint(&self) -> Option<u16> {
        Some(self.levels.len() as u16)
    }
}

Tips

Stay in bounds — Only write to cells within area.x..area.x+area.width and area.y..area.y+area.height. Writing outside may corrupt other widgets.

RefCell for mutation — Since widgets are behind Rc<RefCell<_>>, you can mutate widget state between renders.

Combine with state — Create the widget from state values each render, or store the widget in state and update it.

Performance — The widget's render is called every frame. Keep it fast — no I/O, no allocations if possible.

Troubleshooting

Common issues and how to fix them.

"My app freezes"

You might have a render loop - state being updated during render.

// WRONG - causes infinite loop
fn render(&self, cx: Scope) -> View {
    let count = state!(cx, || 0);
    count.update(|n| *n += 1);  // triggers re-render, which triggers update...
    View::text(format!("{}", count.get()))
}

Fix: Only update state in event handlers or effects, never during render.

"State doesn't update"

Did you forget to call .get()?

// WRONG
View::text(format!("{}", count))  // prints State<i32>, not the value

// RIGHT
View::text(format!("{}", count.get()))

"Hooks called in different order"

If you're using use_state_keyed with explicit keys, this won't happen. The state! macro handles ordering automatically — each call site gets a unique key based on its location in the source code.

// SAFE: state! is order-independent
if show_extra {
    let extra = state!(cx, || "");  // fine in conditionals
}
let main = state!(cx, || 0);  // always works

If you need shared state across call sites, use cx.use_state_keyed::<Key, _>(|| init) with an explicit key type.

"Effect runs too often"

Check your dependency - effects run when the dependency changes.

// Runs every render (String clones are never equal by reference)
effect!(cx, name.get(), |n| { ... });

// Consider: is this the right dependency?

"My component doesn't re-render"

If you're modifying data without going through State::update() or State::set(), the UI won't know to re-render.

// WRONG - modifies state but doesn't trigger re-render
let items = state!(cx, || vec![1, 2, 3]);
items.get().push(4);  // UI won't update!

// RIGHT - use .update() to modify
items.update(|v| v.push(4));  // triggers re-render

"Stream values are stale"

Streams capture their initial context. If you need fresh state values inside a stream, pass them through the stream's data:

// WRONG - max captured once, won't update
let max = max_value.get();
let stream = stream!(cx, || {
    (0..).map(move |i| {
        i % max  // uses stale max
    })
});

// RIGHT - recalculate inside the stream
let stream = stream!(cx, || {
    (0..).map(|i| {
        let current_max = get_current_max();  // get fresh value
        i % current_max
    })
});

Or restart the stream when dependencies change using the refresh pattern from Async Data.

"Terminal shows weird characters"

Unicode rendering issues can occur in terminals with poor Unicode support or incorrect locale settings.

Check your locale:

echo $LANG
# Should show something like: en_US.UTF-8

If not set:

export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8

Terminal compatibility:

  • Use a modern terminal emulator (Kitty, Ghostty, WezTerm, Alacritty)
  • Avoid older terminals that don't fully support Unicode

For emojis and wide characters:

  • Some terminals render wide characters (emoji, CJK) incorrectly
  • If you see overlapping or misaligned text, try a different terminal
  • Telex uses grapheme clustering and width calculations, but terminal rendering varies

Cheatsheet

Quick reference for common patterns.

See the full CHEATSHEET.md in the repository root.

Quick snippets

Basic app

struct App;
impl Component for App {
    fn render(&self, cx: Scope) -> View {
        View::text("Hello")
    }
}
fn main() { telex::run(App).unwrap(); }

State

let count = state!(cx, || 0);
count.get()               // read
count.set(5)              // write
count.update(|n| *n += 1) // modify

Callbacks

with!(count => move || count.update(|n| *n += 1))

Layouts

View::vstack().child(...).child(...).build()
View::hstack().child(...).child(...).build()

Button

View::button().label("Click").on_press(callback).build()

Input

View::text_input().value(v.get()).on_change(cb).build()

List

View::list().items(vec).selected(idx).on_select(cb).build()
View::modal().visible(show.get()).on_dismiss(cb).child(...).build()

Table

View::table()
    .column("Name")
    .column("Value")
    .rows(data)
    .selected(idx)
    .on_select(cb)
    .build()

TextArea

View::text_area()
    .value(text.get())
    .rows(10)
    .on_change(with!(text => move |s| text.set(s)))
    .build()

Tabs

View::tabs()
    .tab("Home", home_view)
    .tab("Settings", settings_view)
    .active(tab.get())
    .on_change(with!(tab => move |idx| tab.set(idx)))
    .build()

Dynamic Data

Stream

let data = stream!(cx, || {
    (0..).map(|i| {
        std::thread::sleep(Duration::from_secs(1));
        i
    })
});
View::text(format!("{}", data.get()))

Effect (one-time)

effect_once!(cx, || {
    println!("Component mounted!");
    || {}  // cleanup
});

Effect (with dependency)

effect!(cx, count.get(), |&val| {
    println!("Count: {}", val);
    || {}  // cleanup
});

Async

let data = async_data!(cx, || {
    fetch_from_api()
});

match &data {
    Async::Loading => View::text("Loading..."),
    Async::Ready(d) => View::text(d),
    Async::Error(e) => View::text(e),
}

Channel

let ch = channel!(cx, String);
// ch.tx() gives WakingSender to pass to threads
// ch.get() returns this frame's messages

Port (bidirectional)

let io = port!(cx, InMsg, OutMsg);
// io.rx.tx() / io.rx.get() for inbound
// io.tx() / io.take_outbound_rx() for outbound

Interval

interval!(cx, Duration::from_secs(1), with!(count => move || {
    count.update(|n| *n += 1);
}));

Reducer

let (state, dispatch) = reducer!(cx, initial, |s, a| {
    match a { /* ... */ }
});

Slider

View::slider()
    .min(0.0).max(100.0).step(1.0)
    .value(val.get())
    .label("Volume")
    .on_change(with!(val => move |v: f64| val.set(v)))
    .build()

Error Boundary

View::error_boundary()
    .child(risky_view)
    .fallback(View::text("Something went wrong"))
    .build()

Custom Widget

View::custom(Rc::new(RefCell::new(my_widget)))

Keyboard Commands

Bind a key

cx.use_command(
    KeyBinding::key(KeyCode::Char('s')).ctrl(true),
    with!(data => move || save(data.get()))
);

Multiple modifiers

KeyBinding::key(KeyCode::Char('q'))
    .ctrl(true)
    .shift(true)

Common Patterns

Toggle boolean

show.update(|v| *v = !*v)

Conditional state (safe in if/else)

if condition {
    let data = state!(cx, || Vec::new());
}

Add to list

items.update(|v| v.push(new_item))

Remove from list

items.update(|v| {
    if idx < v.len() {
        v.remove(idx);
    }
})

Clear list

items.update(|v| v.clear())

For more examples, see the full CHEATSHEET.md in the repository.