# rnk
A React-like declarative terminal UI framework for Rust, inspired by [Ink](https://github.com/vadimdemedes/ink) and [Bubbletea](https://github.com/charmbracelet/bubbletea).
[](https://crates.io/crates/rnk)
[](https://docs.rs/rnk)
[](LICENSE)
## Features
- **React-like API**: Familiar component model with hooks (`use_signal`, `use_effect`, `use_input`, `use_cmd`)
- **Command System**: Elm-inspired side effect management for async tasks, timers, file I/O
- **Declarative UI**: Build TUIs with composable components
- **Flexbox Layout**: Powered by [Taffy](https://github.com/DioxusLabs/taffy) for flexible layouts
- **Inline Mode** (default): Output persists in terminal history (like Ink/Bubbletea)
- **Fullscreen Mode**: Uses alternate screen buffer (like vim)
- **Line-level Diff Rendering**: Only changed lines are redrawn for efficiency
- **Persistent Output**: `println()` API for messages that persist above the UI
- **Cross-thread Rendering**: `request_render()` for async/multi-threaded apps
- **Rich Components**: Box, Text, List, Table, Tabs, Progress, Sparkline, BarChart, and more
- **Mouse Support**: Full mouse event handling
- **Cross-platform**: Works on Linux, macOS, and Windows
## Quick Start
Add to your `Cargo.toml`:
```toml
[dependencies]
rnk = "0.6"
```
## Examples
### Hello World
```rust
use rnk::prelude::*;
fn main() -> std::io::Result<()> {
render(app).run()
}
fn app() -> Element {
Box::new()
.padding(1)
.border_style(BorderStyle::Round)
.child(Text::new("Hello, rnk!").color(Color::Green).bold().into_element())
.into_element()
}
```
### Counter with Keyboard Input
```rust
use rnk::prelude::*;
fn main() -> std::io::Result<()> {
render(app).run()
}
fn app() -> Element {
let count = use_signal(|| 0i32);
let app = use_app();
use_input(move |input, key| {
if input == "q" {
app.exit();
} else if key.up_arrow {
count.update(|c| *c += 1);
} else if key.down_arrow {
count.update(|c| *c -= 1);
}
});
Box::new()
.flex_direction(FlexDirection::Column)
.padding(1)
.child(Text::new(format!("Count: {}", count.get())).bold().into_element())
.child(Text::new("↑/↓ to change, q to quit").dim().into_element())
.into_element()
}
```
### Streaming Output Demo
```rust
use rnk::prelude::*;
use std::time::Duration;
fn main() -> std::io::Result<()> {
// Background thread for periodic updates
std::thread::spawn(|| {
let mut tick = 0u32;
loop {
std::thread::sleep(Duration::from_millis(100));
tick += 1;
rnk::request_render();
// Print persistent log every 20 ticks
if tick % 20 == 0 {
rnk::println(format!("[LOG] Tick {} completed", tick));
}
}
});
render(app).run()
}
fn app() -> Element {
let counter = use_signal(|| 0);
counter.set(counter.get() + 1);
Box::new()
.child(Text::new(format!("Frame: {}", counter.get())).into_element())
.into_element()
}
```
## Render Modes
### Inline Mode (Default)
Output appears at current cursor position and persists in terminal history.
```rust
render(app).run()?; // Inline mode (default)
render(app).inline().run()?; // Explicit inline mode
```
### Fullscreen Mode
Uses alternate screen buffer. Content is cleared on exit.
```rust
render(app).fullscreen().run()?;
```
### Configuration Options
```rust
render(app)
.fullscreen() // Use alternate screen
.fps(30) // Target 30 FPS (default: 60)
.exit_on_ctrl_c(false) // Handle Ctrl+C manually
.run()?;
```
### Runtime Mode Switching
Switch between modes at runtime:
```rust
let app = use_app();
Render elements to string without running the event loop:
```rust
use rnk::prelude::*;
let element = Box::new()
.border_style(BorderStyle::Round)
.child(Text::new("Hello!").into_element())
.into_element();
// Render with specific width
let output = rnk::render_to_string(&element, 80);
println!("{}", output);
// Render with auto-detected terminal width
let output = rnk::render_to_string_auto(&element);
println!("{}", output);
```
## Components
### Box
Flexbox container with full layout support.
```rust
Box::new()
.flex_direction(FlexDirection::Column)
.justify_content(JustifyContent::Center)
.align_items(AlignItems::Center)
.padding(1)
.margin(1.0)
.width(50)
.height(10)
.border_style(BorderStyle::Round)
.border_color(Color::Cyan)
.background(Color::Ansi256(236))
.child(/* ... */)
.into_element()
```
**Border Styles**: `None`, `Single`, `Double`, `Round`, `Bold`, `Custom(chars)`
**Per-side Border Colors**:
```rust
Box::new()
.border_style(BorderStyle::Single)
.border_top_color(Color::Red)
.border_bottom_color(Color::Blue)
.border_left_color(Color::Green)
.border_right_color(Color::Yellow)
```
### Text
Styled text with colors and formatting.
```rust
Text::new("Hello, World!")
.color(Color::Green)
.background_color(Color::Black)
.bold()
.italic()
.underline()
.strikethrough()
.dim()
.into_element()
```
**Rich Text with Spans**:
```rust
Text::builder()
.span("Normal ")
.span_styled("bold", |s| s.bold())
.span(" and ")
.span_styled("colored", |s| s.color(Color::Cyan))
.build()
.into_element()
```
### List
Selectable list with keyboard navigation.
```rust
List::new()
.items(vec!["Item 1", "Item 2", "Item 3"])
.selected(current_index)
.highlight_style(|s| s.color(Color::Cyan).bold())
.on_select(|idx| { /* handle selection */ })
.into_element()
```
### Table
Data table with headers and styling.
```rust
Table::new()
.headers(vec!["Name", "Age", "City"])
.rows(vec![
vec!["Alice", "30", "NYC"],
vec!["Bob", "25", "LA"],
])
.column_widths(vec![20, 10, 15])
.header_style(|s| s.bold().color(Color::Yellow))
.into_element()
```
### Tabs
Tab navigation component.
```rust
Tabs::new()
.tabs(vec!["Home", "Settings", "About"])
.selected(current_tab)
.on_change(|idx| { /* handle tab change */ })
.into_element()
```
### Progress / Gauge
Progress bars and gauges.
```rust
// Simple progress bar
Progress::new()
.progress(0.75) // 75%
.width(30)
.filled_char('█')
.empty_char('░')
.into_element()
// Gauge with label
Gauge::new()
.ratio(0.5)
.label("50%")
.into_element()
```
### Sparkline
Inline data visualization.
```rust
Sparkline::new()
.data(&[1, 3, 7, 2, 5, 8, 4])
.width(20)
.into_element()
```
### BarChart
Horizontal and vertical bar charts.
```rust
BarChart::new()
.data(&[("A", 10), ("B", 20), ("C", 15)])
.bar_width(3)
.bar_gap(1)
.into_element()
```
### Static
Permanent output that persists above dynamic UI.
```rust
Static::new(
items.to_vec(),
|item, index| {
Text::new(format!("[{}] {}", index + 1, item))
.color(Color::Gray)
.into_element()
}
).into_element()
```
### Transform
Transform child text content.
```rust
.into_element()
```
### Spacer / Newline
Layout helpers.
```rust
Box::new()
.flex_direction(FlexDirection::Row)
.child(Text::new("Left").into_element())
.child(Spacer::new().into_element()) // Flexible space
.child(Text::new("Right").into_element())
.into_element()
// Add vertical space
Newline::new().into_element()
```
### Spinner
Animated loading indicator.
```rust
Spinner::new()
.style(SpinnerStyle::Dots)
.label("Loading...")
.into_element()
```
### Message
Styled message boxes for info, success, warning, error.
```rust
Message::info("Information message")
Message::success("Operation completed!")
Message::warning("Please be careful")
Message::error("Something went wrong")
```
## Hooks
### use_signal
Reactive state management.
```rust
let count = use_signal(|| 0);
// Read value
let value = count.get();
// Update value
count.set(value + 1);
// Update with function
Keyboard input handling.
```rust
use_input(move |input, key| {
if input == "q" {
// quit
} else if key.return_key {
// submit
} else if key.up_arrow {
// move up
} else if key.down_arrow {
// move down
}
});
```
**Key struct fields**:
- `up_arrow`, `down_arrow`, `left_arrow`, `right_arrow`
- `page_up`, `page_down`, `home`, `end`
- `return_key`, `escape`, `tab`, `backspace`, `delete`
- `ctrl`, `shift`, `alt` (modifier keys)
### use_mouse
Mouse event handling.
```rust
use_mouse(move |mouse| {
match mouse.action {
MouseAction::Press(MouseButton::Left) => {
println!("Clicked at ({}, {})", mouse.x, mouse.y);
}
MouseAction::Move => { /* handle hover */ }
MouseAction::ScrollUp => { /* scroll up */ }
MouseAction::ScrollDown => { /* scroll down */ }
_ => {}
}
});
```
### use_focus
Focus management for form inputs.
```rust
let focus_state = use_focus(UseFocusOptions {
auto_focus: true,
is_active: true,
id: None,
});
if focus_state.is_focused {
// Component is focused
}
```
### use_scroll
Scroll state management.
```rust
let scroll = use_scroll();
// Configure content and viewport sizes
scroll.set_content_size(100, 500);
scroll.set_viewport_size(80, 20);
let offset_y = scroll.offset_y();
```
### use_app
Application control.
```rust
let app = use_app();
```rust
// No-op command
Cmd::none()
// Batch multiple commands
Cmd::batch(vec![cmd1, cmd2, cmd3])
// Delay execution
Cmd::sleep(Duration::from_secs(1))
// Async task with callback
cmd.and_then(|| another_cmd)
// File operations
Cmd::read_file("path.txt", |content| { /* handle content */ })
Cmd::write_file("path.txt", "content", |success| { /* handle result */ })
// Spawn process
When updating state from background threads:
```rust
use std::thread;
use std::sync::{Arc, RwLock};
fn main() -> std::io::Result<()> {
let shared_data = Arc::new(RwLock::new(String::new()));
let data_clone = Arc::clone(&shared_data);
thread::spawn(move || {
loop {
// Update shared state
*data_clone.write().unwrap() = fetch_data();
// Notify rnk to re-render
rnk::request_render();
thread::sleep(Duration::from_secs(1));
}
});
render(move || app(&shared_data)).run()
}
```
## Println API
Print persistent messages above the UI (inline mode only):
```rust
// Simple text
rnk::println("Task completed!");
// Formatted text
rnk::println(format!("Downloaded {} files", count));
// Rich elements
let banner = Box::new()
.border_style(BorderStyle::Round)
.child(Text::new("Success!").color(Color::Green).into_element())
.into_element();
rnk::println(banner);
```
## Colors
```rust
// Basic colors
Color::Black, Color::Red, Color::Green, Color::Yellow,
Color::Blue, Color::Magenta, Color::Cyan, Color::White,
Color::Gray
// 256 colors
Color::Ansi256(240) // 0-255
// RGB colors
Color::Rgb { r: 255, g: 128, b: 0 }
```
## Testing
rnk provides testing utilities for verifying UI components:
```rust
use rnk::testing::{TestRenderer, assert_layout_valid};
#[test]
fn test_component() {
let element = my_component();
// Validate layout
let renderer = TestRenderer::new(80, 24);
renderer.validate_layout(&element).expect("valid layout");
// Check rendered output
let output = rnk::render_to_string(&element, 80);
assert!(output.contains("expected text"));
}
```
## Running Examples
```bash
# Hello world
cargo run --example hello
# Interactive counter
cargo run --example counter
# Streaming output demo
cargo run --example streaming_demo
# Static rendering API demo
cargo run --example render_api_demo
# GLM chat demo
cargo run --example glm_chat
```
## Architecture
```
src/
├── components/ # UI components (Box, Text, List, etc.)
├── core/ # Element, Style, Color primitives
├── hooks/ # React-like hooks (use_signal, use_effect, etc.)
├── layout/ # Taffy-based flexbox layout engine
├── renderer/ # Terminal rendering, App runner
└── testing/ # Test utilities
```
## Comparison with Ink/Bubbletea
| Language | Rust | JavaScript | Go |
| Rendering | Line-level diff | Line-level diff | Line-level diff |
| Layout | Flexbox (Taffy) | Flexbox (Yoga) | Manual |
| State | Hooks | React hooks | Model-Update |
| Inline mode | ✓ | ✓ | ✓ |
| Fullscreen | ✓ | ✓ | ✓ |
| Println | ✓ | Static component | tea.Println |
| Cross-thread | request_render() | - | tea.Program.Send |
## License
MIT