Skip to content

Teapot — a Rust-native terminal UI framework with Elm-inspired architecture.

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT
Notifications You must be signed in to change notification settings

inferadb/teapot

Teapot

Discord License

A Rust Terminal UI framework inspired by Bubble Tea

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

Installation

Run the following Cargo command in your project directory:

cargo add teapot

Quick Start

use 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(())
}

Components

Text Input

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("> ");

Text Area

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);

External Editor Support

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 highlighting

Selection Components

Select

Single-choice selection from a list of options.

use teapot::components::Select;

let select = Select::new("Choose a color")
    .options(vec!["Red", "Green", "Blue"]);

MultiSelect

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);

Confirm

Yes/No confirmation prompt with customizable default.

use teapot::components::Confirm;

let confirm = Confirm::new("Are you sure?")
    .default(false);

List

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);

Progress Indicators

Spinner

Animated loading indicator for indeterminate operations.

use teapot::components::{Spinner, SpinnerStyle};

let spinner = Spinner::new()
    .style(SpinnerStyle::Dots)
    .message("Loading...");

Progress

Progress bar for operations with known completion percentage.

use teapot::components::Progress;

let progress = Progress::new()
    .total(100)
    .current(45)
    .message("Downloading...");

MultiProgress

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);

Display Components

Viewport

Scrollable container for long content with keyboard navigation.

use teapot::components::Viewport;

let viewport = Viewport::new(80, 20)
    .content("Long scrollable content here...");

Table

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);

Forms

Build multi-step forms with validation, inspired by Huh.

Basic Form

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())
    );

Form Layouts

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));

All Field Types

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();

Dynamic Content

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();

FilePicker Component

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();

Styling

Teapot includes a comprehensive styling system inspired by Lip Gloss.

Basic Styling

use teapot::style::{Style, Color, Border};

let styled = Style::new()
    .fg(Color::Cyan)
    .bg(Color::Black)
    .bold()
    .italic()
    .border(Border::Rounded)
    .render("Hello, World!");

CSS-like Shorthand

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]);

Block Dimensions

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);

Layout Utilities

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!");

Adaptive Colors

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
};

Style Inheritance

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();

Program Configuration

Builder Pattern

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()?;

Message Filtering

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()?;

Commands

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()

External Process Execution

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,
    }
})

Architecture

The framework follows The Elm Architecture:

  1. Model - Your application state (any Rust struct)
  2. Message - Events that trigger state changes
  3. Update - Pure function that handles messages and updates state
  4. View - Pure function that renders state as a string
  5. 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
Loading

CI/Script Compatibility

The framework automatically detects non-interactive environments:

  • No animations or spinners
  • Clear error messages
  • Appropriate exit codes
  • Works with piped input/output

Accessibility

Teapot supports accessible mode for screen reader users and other assistive technologies.

Enabling Accessible Mode

Set the ACCESSIBLE environment variable:

ACCESSIBLE=1 ./my-app

What Changes in Accessible Mode

  • 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

Accessible Forms

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),
}

Example Accessible Session

=== 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!

Custom Accessible Components

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
    }
}

Environment Variables

Variable Description
ACCESSIBLE=1 Enable accessible mode
NO_COLOR=1 Disable colors (respected automatically)
REDUCE_MOTION=1 Disable animations

Community

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!

License

Licensed under either of:

at your option.

About

Teapot — a Rust-native terminal UI framework with Elm-inspired architecture.

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Languages