Important
Under active development. Not production-ready.
teapot provides a functional, declarative approach to building terminal user interfaces:
- Model-Update-View - Core architecture based on The Elm Architecture
- Composable Components - Reusable widgets like spinners, inputs, and selectors
- Form System - Declarative form building with validation
- CI-Friendly - Automatic non-interactive mode detection
Run the following Cargo command in your project directory:
cargo add teapotuse teapot::{Model, Program, Cmd, Event, KeyCode};
struct Counter {
count: i32,
}
enum Msg {
Increment,
Decrement,
Quit,
}
impl Model for Counter {
type Message = Msg;
fn init(&self) -> Option<Cmd<Self::Message>> {
None
}
fn update(&mut self, msg: Self::Message) -> Option<Cmd<Self::Message>> {
match msg {
Msg::Increment => self.count += 1,
Msg::Decrement => self.count -= 1,
Msg::Quit => return Some(Cmd::quit()),
}
None
}
fn view(&self) -> String {
format!("Count: {}\n\nPress +/- to change, q to quit", self.count)
}
fn handle_event(&self, event: Event) -> Option<Self::Message> {
match event {
Event::Key(key) => match key.code {
KeyCode::Char('+') => Some(Msg::Increment),
KeyCode::Char('-') => Some(Msg::Decrement),
KeyCode::Char('q') => Some(Msg::Quit),
_ => None,
},
_ => None,
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
Program::new(Counter { count: 0 }).run()?;
Ok(())
}Single-line text input with cursor support, placeholder text, and optional password masking.
use teapot::components::TextInput;
let input = TextInput::new()
.placeholder("Enter your name...")
.prompt("> ");Multi-line text editor with cursor navigation, scrolling, and line editing.
use teapot::components::TextArea;
let textarea = TextArea::new()
.placeholder("Enter your message...")
.height(10)
.width(60);Open content in your preferred editor with Ctrl+O:
let textarea = TextArea::new()
.placeholder("Enter code...")
.editor("code --wait") // Use VS Code (default: $VISUAL or $EDITOR)
.editor_extension("rs"); // File extension for syntax highlightingSingle-choice selection from a list of options.
use teapot::components::Select;
let select = Select::new("Choose a color")
.options(vec!["Red", "Green", "Blue"]);Multiple-choice selection with checkboxes and optional min/max constraints.
use teapot::components::MultiSelect;
let select = MultiSelect::new("Choose colors")
.options(vec!["Red", "Green", "Blue"])
.min(1)
.max(2);Yes/No confirmation prompt with customizable default.
use teapot::components::Confirm;
let confirm = Confirm::new("Are you sure?")
.default(false);Filterable, paginated list with keyboard navigation and search.
use teapot::components::List;
let list = List::new("Select a file")
.items(vec!["main.rs", "lib.rs", "Cargo.toml"])
.height(10)
.filterable(true);Animated loading indicator for indeterminate operations.
use teapot::components::{Spinner, SpinnerStyle};
let spinner = Spinner::new()
.style(SpinnerStyle::Dots)
.message("Loading...");Progress bar for operations with known completion percentage.
use teapot::components::Progress;
let progress = Progress::new()
.total(100)
.current(45)
.message("Downloading...");Parallel progress bars for tracking multiple concurrent tasks.
use teapot::components::MultiProgress;
let mp = MultiProgress::new()
.add_task("download", "Downloading files...", 100)
.add_task("compile", "Compiling...", 50)
.add_task("test", "Running tests...", 200);Scrollable container for long content with keyboard navigation.
use teapot::components::Viewport;
let viewport = Viewport::new(80, 20)
.content("Long scrollable content here...");Data table with columns, alignment options, and row selection.
use teapot::components::{Table, Column};
let table = Table::new()
.columns(vec![
Column::new("Name").width(20),
Column::new("Age").width(5),
Column::new("City").width(15),
])
.rows(vec![
vec!["Alice", "30", "New York"],
vec!["Bob", "25", "Los Angeles"],
])
.height(10);Build multi-step forms with validation, inspired by Huh.
use teapot::forms::{Form, Group, InputField, SelectField, ConfirmField};
let form = Form::new()
.title("User Registration")
.group(
Group::new()
.title("Personal Info")
.field(InputField::new("name").title("Your name").required().build())
.field(InputField::new("email").title("Email").build())
)
.group(
Group::new()
.title("Preferences")
.field(SelectField::new("theme").title("Theme")
.options(["Light", "Dark", "System"]).build())
.field(ConfirmField::new("newsletter").title("Subscribe?").build())
);Control how form groups are displayed:
use teapot::forms::{Form, FormLayout};
// Default: one group at a time (wizard-style)
let wizard = Form::new().layout(FormLayout::Default);
// Stack: all groups visible at once
let stacked = Form::new().layout(FormLayout::Stack);
// Columns: side-by-side layout
let columns = Form::new().layout(FormLayout::Columns(2));use teapot::forms::{
InputField, SelectField, MultiSelectField, ConfirmField,
NoteField, FilePickerField
};
// Text input with validation
InputField::new("email")
.title("Email Address")
.placeholder("[email protected]")
.required()
.build();
// Single selection
SelectField::new("country")
.title("Country")
.options(["USA", "Canada", "UK", "Germany"])
.build();
// Multiple selection with constraints
MultiSelectField::new("languages")
.title("Languages")
.options(["Rust", "Go", "Python", "TypeScript"])
.min(1)
.max(3)
.build();
// Yes/No confirmation
ConfirmField::new("agree")
.title("Accept terms?")
.default(false)
.build();
// Display-only note
NoteField::new("Please review carefully before proceeding.")
.title("Important")
.build();
// File/directory picker
FilePickerField::new("config_file")
.title("Select config file")
.directory("/etc")
.extensions(["toml", "yaml", "json"])
.build();Field titles and descriptions can update dynamically:
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let attempt = Arc::new(AtomicUsize::new(1));
let attempt_clone = attempt.clone();
InputField::new("password")
.title_fn(move || format!("Password (attempt {})", attempt_clone.load(Ordering::SeqCst)))
.description_fn(|| "Must be at least 8 characters".to_string())
.build();Browse and select files/directories:
use teapot::components::FilePicker;
let picker = FilePicker::new()
.title("Select a file")
.directory("/home/user/projects")
.extensions(["rs", "toml"]) // Filter by extension
.show_hidden(false) // Hide dotfiles
.height(15); // Visible rows
// Or for directory selection only
let dir_picker = FilePicker::new()
.title("Select output directory")
.dirs_only();Teapot includes a comprehensive styling system inspired by Lip Gloss.
use teapot::style::{Style, Color, Border};
let styled = Style::new()
.fg(Color::Cyan)
.bg(Color::Black)
.bold()
.italic()
.border(Border::Rounded)
.render("Hello, World!");Padding and margin support CSS-style shorthand (1, 2, 3, or 4 values):
use teapot::style::Style;
// All sides: 2
Style::new().padding(&[2]);
// Vertical: 1, Horizontal: 2
Style::new().padding(&[1, 2]);
// Top: 1, Horizontal: 2, Bottom: 3
Style::new().margin(&[1, 2, 3]);
// Top: 1, Right: 2, Bottom: 3, Left: 4 (clockwise)
Style::new().margin(&[1, 2, 3, 4]);Control width, height, and alignment:
use teapot::style::{Style, Position};
let box_style = Style::new()
.width(40)
.height(10)
.max_width(80)
.align(Position::Center, Position::Center)
.border(Border::Rounded);Compose blocks horizontally or vertically:
use teapot::style::{join_horizontal_with, join_vertical_with, place, Position};
// Side-by-side blocks (aligned at top)
let combined = join_horizontal_with(Position::Top, &[left_block, right_block]);
// Stacked blocks (centered horizontally)
let stacked = join_vertical_with(Position::Center, &[header, content, footer]);
// Position content in a box
let centered = place(80, 24, Position::Center, Position::Center, "Centered!");Colors that adapt to light/dark terminal backgrounds:
use teapot::style::Color;
// Different colors for light vs dark backgrounds
let adaptive = Color::Adaptive {
light: Box::new(Color::Ansi256(236)), // Dark gray for light bg
dark: Box::new(Color::Ansi256(252)), // Light gray for dark bg
};
// Full color specification for all terminal types
let complete = Color::Complete {
true_color: "#ff6600".to_string(),
ansi256: 208,
ansi: 3, // Yellow fallback
};Build styles incrementally:
use teapot::style::Style;
let base = Style::new()
.fg(Color::White)
.bold();
let highlight = Style::new()
.inherit(&base) // Copy unset properties from base
.bg(Color::Blue);
// Unset specific properties
let plain = highlight.unset_bold().unset_bg();Configure the program with a fluent builder API:
use teapot::{Program, Model};
use std::time::Duration;
Program::new(my_model)
.with_alt_screen() // Use alternate screen buffer
.with_mouse() // Enable mouse events
.with_bracketed_paste() // Enable paste detection
.with_focus_change() // Enable focus/blur events
.with_tick_rate(Duration::from_millis(16)) // ~60 FPS
.with_accessible() // Force accessible mode
.run()?;Pre-process or block messages before they reach your update function:
Program::new(my_model)
.with_filter(|model, msg| {
// Block all messages while loading
if model.is_loading {
return None;
}
// Transform or pass through
Some(msg)
})
.run()?;The cmd module provides Bubble Tea-style command functions:
use teapot::cmd;
use std::time::Duration;
// Quit the program
cmd::quit()
// Batch multiple commands
cmd::batch(vec![cmd1, cmd2, cmd3])
// Sequential execution
cmd::sequence(vec![cmd1, cmd2, cmd3])
// Periodic tick
cmd::tick(Duration::from_secs(1), |_| Msg::Tick)
// No-op command
cmd::none()Spawn external processes (editors, etc.) with terminal teardown/restore:
use teapot::Cmd;
use std::process::Command;
let mut cmd = Command::new("vim");
cmd.arg("file.txt");
Cmd::run_process(cmd, |result| {
match result {
Ok(status) => Msg::EditorClosed(status.success()),
Err(_) => Msg::EditorFailed,
}
})The framework follows The Elm Architecture:
- Model - Your application state (any Rust struct)
- Message - Events that trigger state changes
- Update - Pure function that handles messages and updates state
- View - Pure function that renders state as a string
- Commands - Side effects (timers, async operations)
flowchart TD
subgraph Runtime["Runtime Loop"]
direction LR
Model["Model<br/>(State)"]
View["View<br/>(Render)"]
Update["Update<br/>(Logic)"]
end
Model --> View
View -->|"returns String"| Terminal["Terminal Output"]
Events["User Events"] --> Update
Update -->|"New Model + Cmd"| Model
subgraph Commands["Commands (Effects)"]
Tick["Tick timers"]
Async["Async I/O"]
Quit["Quit signal"]
end
Update --> Commands
Commands --> Update
The framework automatically detects non-interactive environments:
- No animations or spinners
- Clear error messages
- Appropriate exit codes
- Works with piped input/output
Teapot supports accessible mode for screen reader users and other assistive technologies.
Set the ACCESSIBLE environment variable:
ACCESSIBLE=1 ./my-app- Plain text output - No ANSI escape codes or visual formatting
- Numbered options - Selection components use numbers instead of arrow navigation
- Line-based input - Standard stdin reading instead of raw terminal mode
- Clear prompts - Screen reader-friendly text descriptions
Use Form::run_accessible() for a fully accessible form experience:
use teapot::forms::{Form, Group, InputField, SelectField, ConfirmField};
let mut form = Form::new()
.title("User Survey")
.group(
Group::new()
.field(InputField::new("name").title("Your name").build())
.field(SelectField::new("color").title("Favorite color")
.options(["Red", "Green", "Blue"]).build())
.field(ConfirmField::new("subscribe").title("Subscribe to newsletter?").build())
);
// Run in accessible mode (line-based prompts)
match form.run_accessible() {
Ok(Some(results)) => {
println!("Name: {}", results.get_string("name").unwrap_or(""));
println!("Color: {}", results.get_string("color").unwrap_or(""));
println!("Subscribe: {}", results.get_bool("subscribe").unwrap_or(false));
}
Ok(None) => println!("Form cancelled"),
Err(e) => eprintln!("Error: {}", e),
}=== User Survey ===
Your name
?
> Alice
Favorite color
? Favorite color
1) Red
2) Green
* 3) Blue
Enter number (or q to cancel): 3
Subscribe to newsletter?
? Subscribe to newsletter? (y/N) y
Form completed!
Components can implement the Accessible trait for custom accessible rendering:
use teapot::{Accessible, Model};
impl Accessible for MyComponent {
type Message = MyMsg;
fn accessible_prompt(&self) -> String {
// Return plain text prompt
format!("? {}\n> ", self.title)
}
fn parse_accessible_input(&self, input: &str) -> Option<Self::Message> {
// Parse line input and return message
Some(MyMsg::SetValue(input.trim().to_string()))
}
fn is_accessible_complete(&self) -> bool {
self.submitted
}
}| Variable | Description |
|---|---|
ACCESSIBLE=1 |
Enable accessible mode |
NO_COLOR=1 |
Disable colors (respected automatically) |
REDUCE_MOTION=1 |
Disable animations |
Join us on Discord to discuss InferaDB, get help with your projects, and connect with other developers. Whether you have questions, want to share what you're building, or are interested in contributing, we'd love to have you!
Licensed under either of:
at your option.
