Hojicha
The Elm Architecture for Terminal UIs in Rust

Hojicha implements The Elm Architecture for terminal applications in Rust, inspired by Charm's Bubbletea.
Features
- The Elm Architecture - Clean Model-View-Update pattern for terminal UIs
- Async Support - Native async/await integration and cancellable operations
- Performance - Zero-cost abstractions with optimized event processing
- Testing - Comprehensive test harness with time control and event simulation
- Error Handling - Robust panic recovery and structured error types
- Cross-platform - Works on Windows, macOS, and Linux
Installation
[dependencies]
hojicha = "0.2"
hojicha-core = "0.2"
hojicha-runtime = "0.2"
Quick Start
use hojicha::prelude::*;
struct Counter {
value: i32,
}
impl Model for Counter {
type Message = ();
fn update(&mut self, event: Event<()>) -> Cmd<()> {
match event {
Event::Key(key) => match key.key {
Key::Up => self.value += 1,
Key::Down => self.value -= 1,
Key::Char('q') => return quit(),
_ => {}
},
_ => {}
}
Cmd::noop()
}
fn view(&self) -> String {
format!(
"╭─────────────────────╮\n\
│ Counter │\n\
├─────────────────────┤\n\
│ Value: {:^11} │\n\
├─────────────────────┤\n\
│ Up/Down: change │\n\
│ q: quit │\n\
╰─────────────────────╯",
self.value
)
}
}
fn main() -> Result<()> {
Program::new(Counter { value: 0 })?.run()
}
Architecture
The Elm Architecture consists of:
| Component |
Purpose |
| Model |
Application state |
| Message |
Events that trigger state changes |
| Update |
Handle events and update state |
| View |
Render UI from state |
| Command |
Side effects (async operations, I/O) |
Common Patterns
Async Operations
use hojicha_core::commands;
fn update(&mut self, event: Event<Msg>) -> Cmd<Msg> {
match event {
Event::User(Msg::FetchData) => {
commands::spawn(async {
let data = fetch_api().await.ok()?;
Some(Msg::DataLoaded(data))
})
}
_ => Cmd::noop()
}
}
Timers
fn init(&mut self) -> Cmd<Msg> {
commands::every(Duration::from_secs(1), |_| Msg::Tick)
}
Command Composition
let cmd = commands::batch(vec![
commands::spawn(async { Some(Msg::LoadData) }),
commands::tick(Duration::from_millis(100), || Msg::Refresh),
]);
Core Features
Hojicha provides:
Event Handling: Keyboard, mouse, resize, and custom events
Async Support: Commands for HTTP, file I/O, timers, and background tasks
Testing: Comprehensive test harness with time control and event simulation
Performance: Zero-cost abstractions with optimized event processing
Testing
use hojicha_core::testing::TestHarness;
#[test]
fn test_counter() {
let result = TestHarness::new(Counter { value: 0 })
.send_event(Event::Key(KeyEvent::from_char('+')))
.run();
assert_eq!(result.model.value, 1);
}
Performance Metrics
let program = Program::new(model)?
.with_priority_config(PriorityConfig {
enable_metrics: true,
..Default::default()
});
program.metrics_json();
program.metrics_prometheus();
Advanced Features
Priority Event Processing
- Critical: Quit, suspend
- High: User input (keyboard, mouse)
- Normal: User messages, timers
- Low: Resize, background tasks
Cancellable Operations
let handle = program.spawn_cancellable(|token| async move {
while !token.is_cancelled() {
process_batch().await;
}
});
Documentation
Examples
Here's a complete working example:
use hojicha::prelude::*;
use std::time::Duration;
#[derive(Debug)]
struct TodoApp {
todos: Vec<String>,
input: String,
}
#[derive(Debug, Clone)]
enum Message {
AddTodo,
UpdateInput(String),
ClearCompleted,
Tick,
}
impl Model for TodoApp {
type Message = Message;
fn init(&mut self) -> Cmd<Message> {
commands::every(Duration::from_secs(1), |_| Message::Tick)
}
fn update(&mut self, event: Event<Message>) -> Cmd<Message> {
match event {
Event::Key(key) => match key.key {
Key::Char('q') => commands::quit(),
Key::Char('\n') => {
if !self.input.is_empty() {
self.todos.push(self.input.clone());
self.input.clear();
}
Cmd::noop()
}
Key::Char(c) => {
self.input.push(c);
Cmd::noop()
}
Key::Backspace => {
self.input.pop();
Cmd::noop()
}
_ => Cmd::noop(),
},
Event::User(Message::Tick) => {
Cmd::noop()
}
_ => Cmd::noop(),
}
}
fn view(&self) -> String {
let mut output = String::new();
output.push_str("Todo App\n");
output.push_str("--------\n");
for (i, todo) in self.todos.iter().enumerate() {
output.push_str(&format!("{}. {}\n", i + 1, todo));
}
output.push_str("\nAdd todo: ");
output.push_str(&self.input);
output.push_str("\n\nPress 'q' to quit, Enter to add todo");
output
}
}
fn main() -> Result<()> {
let app = TodoApp {
todos: vec!["Learn Rust".to_string(), "Build a TUI".to_string()],
input: String::new(),
};
Program::new(app)?.run()
}
Contributing
Contributions are welcome! Please check the issues page and feel free to submit pull requests.
License
GPL-3.0