Introduction
⚠️ 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 viacargo doc --open.
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:
- Getting Started - Installation, first app, core concepts
- Building UIs - Layouts, lists, inputs, modals
- Dynamic Data - Streams, effects, async loading
- Widgets - Tables, trees, tabs, forms, menus
- 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:
-
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.). -
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:
| Theme | Style |
|---|---|
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:
- Stacks arrange children along one axis -
VStackgoes top-to-bottom,HStackgoes left-to-right - Children have intrinsic sizes - a button is 1 line tall, text wraps to fit width
- Flex distributes extra space - children with
flex(1)grow to fill remaining room - Constraints set boundaries -
min_height,max_heightlimit 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_selectwith 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.
Modal state management
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
- Start: When the component first renders, the async task starts immediately
- Loading:
Async::Loadinguntil the task completes - Complete: Once finished, becomes either
Async::Ready(data)orAsync::Error(msg) - 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 aWakingSender<T>that can be cloned and sent to any threadch.get()— Returns aVec<T>of messages received this framech.len()— Number of messages this framech.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— AChannelHandle<In>for inbound messages (same API aschannel!)io.tx()— ASender<Out>for outbound messagesio.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 lifetime — WakingSender 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— Astd::time::Durationfor the interval periodcallback— 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(¤t_items[idx]);
current_items = ¤t_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
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 validSome(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()
Menu structure
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
Menu state tracking
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::TopRightToastPosition::TopLeftToastPosition::BottomLeftToastPosition::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:
- State changes
render()is called, returns aView- View is displayed to the screen
- 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
| Type | When it runs | Use for |
|---|---|---|
effect_once! | Once on mount | Initialization, setup |
effect! | When dependency changes | Reacting 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:
- Encodes image data as base64
- Generates Kitty graphics protocol escape sequences
- Writes sequences to terminal
- Kitty decodes and displays the image as pixel overlay
- 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 itNoneif 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:
- Check that an ancestor calls
provide_contextfor that type - Verify the type matches exactly (including generics)
- Ensure the provider is rendered before the consumer
- 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
| Feature | Context | Shared State | Props |
|---|---|---|---|
| Scope | Subtree | Global | Direct child |
| Mutability | Immutable snapshots | Mutable | Immutable |
| Type safety | Runtime (Option<T>) | Compile-time | Compile-time |
| Override | Yes (nested providers) | No | Yes (pass different value) |
| Best for | Config, theme, session | UI state, selections | Local 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— AState<S>handle (same asstate!returns)dispatch— AnRc<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()onNoneorErrpanic!()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— ARect { x, y, width, height }defining the available spacebuf— ABufferto 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()
Modal
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.