Crates.io | hojicha |
lib.rs | hojicha |
version | 0.2.2 |
created_at | 2025-08-07 17:40:43.306609+00 |
updated_at | 2025-08-29 17:13:16.896906+00 |
description | Elm Architecture for terminal UIs in Rust, inspired by Bubbletea |
homepage | https://jgok76.gitea.cloud/femtomc/hojicha |
repository | https://jgok76.gitea.cloud/femtomc/hojicha |
max_upload_size | |
id | 1785588 |
size | 41,552 |
The Elm Architecture for Terminal UIs in Rust
Hojicha implements The Elm Architecture for terminal applications in Rust, inspired by Charm's Bubbletea.
[dependencies]
hojicha = "0.2" # Framework + runtime (recommended)
# Or use individual crates:
hojicha-core = "0.2" # Core framework only
hojicha-runtime = "0.2" # Runtime and program management
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()
}
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) |
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()
}
}
fn init(&mut self) -> Cmd<Msg> {
commands::every(Duration::from_secs(1), |_| Msg::Tick)
}
// Combine multiple commands
let cmd = commands::batch(vec![
commands::spawn(async { Some(Msg::LoadData) }),
commands::tick(Duration::from_millis(100), || Msg::Refresh),
]);
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
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);
}
let program = Program::new(model)?
.with_priority_config(PriorityConfig {
enable_metrics: true,
..Default::default()
});
// Export metrics
program.metrics_json();
program.metrics_prometheus();
let handle = program.spawn_cancellable(|token| async move {
while !token.is_cancelled() {
process_batch().await;
}
});
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> {
// Start a timer that ticks every second
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) => {
// Handle periodic updates
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()
}
Contributions are welcome! Please check the issues page and feel free to submit pull requests.
GPL-3.0