rnk
A React-like declarative terminal UI framework for Rust, inspired by Ink and Bubbletea.

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
- Type-safe Commands:
TypedCmd<M> for compile-time message type checking
- Declarative Macros:
row!, col!, text! for concise UI building
- Declarative UI: Build TUIs with composable components
- Flexbox Layout: Powered by 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: 40+ components including Box, Text, List, Table, Tree, Modal, Notification, and more
- Mouse Support: Full mouse event handling
- Bracketed Paste: Distinguish between typed and pasted text
- Theme System: Centralized theming with semantic colors
- Cross-platform: Works on Linux, macOS, and Windows
Quick Start
Add to your Cargo.toml:
[dependencies]
rnk = "0.10"
Examples
Hello World
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()
}
Using Declarative Macros
use rnk::prelude::*;
use rnk::{col, row, text, spacer};
fn app() -> Element {
col! {
text!("Header").bold(),
row! {
text!("Left"),
spacer!(),
text!("Right"),
},
text!("Footer").dim(),
}
}
Counter with Keyboard Input
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()
}
Type-safe Commands with TypedCmd
use rnk::prelude::*;
use rnk::cmd::TypedCmd;
use std::time::Duration;
#[derive(Clone, Debug)]
enum Msg {
DataLoaded(Vec<String>),
Error(String),
Tick(u64),
}
fn app() -> Element {
let data = use_signal(|| Vec::new());
let tick = use_signal(|| 0u64);
use_cmd_once(move || {
TypedCmd::batch(vec![
TypedCmd::perform(
|| async { vec!["Item 1".into(), "Item 2".into()] },
Msg::DataLoaded,
),
TypedCmd::tick(Duration::from_secs(1), |_| Msg::Tick(1)),
])
.on_msg(move |msg| {
match msg {
Msg::DataLoaded(items) => data.set(items),
Msg::Tick(n) => tick.update(|t| *t += n),
Msg::Error(_) => {}
}
})
});
col! {
text!("Data: {:?}", data.get()),
text!("Ticks: {}", tick.get()),
}
}
Render Modes
Inline Mode (Default)
Output appears at current cursor position and persists in terminal history.
render(app).run()?; render(app).inline().run()?;
Fullscreen Mode
Uses alternate screen buffer. Content is cleared on exit.
render(app).fullscreen().run()?;
Configuration Options
render(app)
.fullscreen() .fps(30) .exit_on_ctrl_c(false) .run()?;
Runtime Mode Switching
Switch between modes at runtime:
let app = use_app();
use_input(move |input, _key| {
if input == " " {
if rnk::is_alt_screen().unwrap_or(false) {
rnk::exit_alt_screen(); } else {
rnk::enter_alt_screen(); }
}
});
Components
Layout Components
| Component |
Description |
Box |
Flexbox container with full layout support |
Spacer |
Flexible space filler |
Newline |
Vertical space |
Transform |
Transform child text content |
Text & Display
| Component |
Description |
Text |
Styled text with colors and formatting |
Gradient |
Text with gradient colors |
Hyperlink |
Clickable terminal hyperlinks |
Spinner |
Animated loading indicator |
Cursor |
Blinking cursor component |
Data Display
| Component |
Description |
List |
Selectable list with keyboard navigation |
Table |
Data table with headers and styling |
Tree |
Hierarchical tree view |
Progress / Gauge |
Progress bars |
Sparkline |
Inline data visualization |
BarChart |
Horizontal/vertical bar charts |
Scrollbar |
Scrollbar indicator |
Input Components
| Component |
Description |
TextInput |
Single-line text input |
TextArea |
Multi-line text editor with vim keybindings |
SelectInput |
Dropdown-style selection |
MultiSelect |
Multiple item selection |
Confirm |
Yes/No confirmation dialog |
FilePicker |
File system browser |
Navigation
| Component |
Description |
Tabs |
Tab navigation |
Paginator |
Page navigation (dots, numbers, arrows) |
Viewport |
Scrollable content viewport |
Help |
Keyboard shortcut help display |
Feedback & Overlay
| Component |
Description |
Modal |
Modal overlay |
Dialog |
Dialog box with buttons |
Notification / Toast |
Notification messages |
Message |
Styled message boxes (info, success, warning, error) |
Static |
Permanent output above dynamic UI |
Theming
| Component |
Description |
Theme |
Centralized theme configuration |
ThemeBuilder |
Fluent theme construction |
Example: Box
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()
Example: Tree
let root = TreeNode::new("root", "Root")
.child(TreeNode::new("child1", "Child 1")
.child(TreeNode::new("grandchild", "Grandchild")))
.child(TreeNode::new("child2", "Child 2"));
Tree::new(root)
.expanded(&["root", "child1"])
.selected("grandchild")
.into_element()
Example: Modal
Modal::new()
.visible(show_modal.get())
.align(ModalAlign::Center)
.child(
Dialog::new()
.title("Confirm")
.message("Are you sure?")
.buttons(vec!["Yes", "No"])
.on_select(|idx| { })
.into_element()
)
.into_element()
Example: Notification
Notification::new()
.items(notifications.get())
.position(NotificationPosition::TopRight)
.max_visible(3)
.into_element()
Hooks
State Management
| Hook |
Description |
use_signal |
Reactive state management |
use_memo |
Memoized computation |
use_callback |
Memoized callback |
Effects & Commands
| Hook |
Description |
use_effect |
Side effects with dependencies |
use_effect_once |
One-time side effect |
use_cmd |
Command execution with dependencies |
use_cmd_once |
One-time command execution |
Input
| Hook |
Description |
use_input |
Keyboard input handling |
use_mouse |
Mouse event handling |
use_paste |
Bracketed paste handling |
use_text_input |
Text input state management |
Focus & Navigation
| Hook |
Description |
use_focus |
Focus state for a component |
use_focus_manager |
Global focus management |
use_scroll |
Scroll state management |
Application
| Hook |
Description |
use_app |
Application control (exit, etc.) |
use_window_title |
Set terminal window title |
use_frame_rate |
Frame rate monitoring |
use_measure |
Measure element dimensions |
use_stdin / use_stdout / use_stderr |
Stdio access |
Example: use_paste
use_paste(move |event| {
match event {
PasteEvent::Start => { }
PasteEvent::Content(text) => {
input_buffer.update(|b| b.push_str(&text));
}
PasteEvent::End => { }
}
});
Example: use_memo
let items = use_signal(|| vec![1, 2, 3, 4, 5]);
let sum = use_memo(
move || items.get().iter().sum::<i32>(),
vec![items.get().len()],
);
Command System
Basic Commands
use rnk::cmd::Cmd;
Cmd::none()
Cmd::batch(vec![cmd1, cmd2, cmd3])
Cmd::sequence(vec![cmd1, cmd2, cmd3])
Cmd::sleep(Duration::from_secs(1))
Cmd::tick(Duration::from_secs(1), |timestamp| { })
Cmd::every(Duration::from_secs(1), |timestamp| { })
Cmd::perform(|| async { })
cmd.and_then(another_cmd)
Terminal Control Commands
Cmd::clear_screen()
Cmd::hide_cursor()
Cmd::show_cursor()
Cmd::set_window_title("My App")
Cmd::window_size()
Cmd::enter_alt_screen()
Cmd::exit_alt_screen()
Cmd::enable_mouse()
Cmd::disable_mouse()
Cmd::enable_bracketed_paste()
Cmd::disable_bracketed_paste()
External Process Execution
Cmd::exec_cmd("vim", &["file.txt"], |result| {
match result {
ExecResult::Success(code) => { }
ExecResult::Error(err) => { }
}
})
Type-safe Commands (TypedCmd)
use rnk::cmd::TypedCmd;
#[derive(Clone)]
enum Msg {
Loaded(String),
Error(String),
}
let cmd: TypedCmd<Msg> = TypedCmd::perform(
|| async { "data".to_string() },
Msg::Loaded,
);
cmd.on_msg(|msg| {
match msg {
Msg::Loaded(data) => { }
Msg::Error(err) => { }
}
})
Declarative Macros
use rnk::{col, row, text, styled_text, spacer, box_element, when, list};
col! {
text!("Line 1"),
text!("Line 2"),
}
row! {
text!("Left"),
spacer!(),
text!("Right"),
}
text!("Count: {}", count)
styled_text!("Error!", color: Color::Red)
when!(show_error => text!("Error occurred!"))
list!(items.iter(), |item| text!("{}", item))
list_indexed!(items.iter(), |idx, item| text!("[{}] {}", idx, item))
Theme System
use rnk::components::{Theme, ThemeBuilder, set_theme, get_theme, with_theme};
let theme = ThemeBuilder::new()
.primary(Color::Cyan)
.secondary(Color::Magenta)
.success(Color::Green)
.warning(Color::Yellow)
.error(Color::Red)
.build();
set_theme(theme);
let color = get_theme().semantic_color(SemanticColor::Primary);
with_theme(dark_theme, || {
});
Cross-thread Rendering
use std::thread;
fn main() -> std::io::Result<()> {
thread::spawn(|| {
loop {
rnk::request_render();
rnk::println("Background task completed");
thread::sleep(Duration::from_secs(1));
}
});
render(app).run()
}
Testing
use rnk::testing::{TestRenderer, assert_layout_valid};
#[test]
fn test_component() {
let element = my_component();
let renderer = TestRenderer::new(80, 24);
renderer.validate_layout(&element).expect("valid layout");
let output = rnk::render_to_string(&element, 80);
assert!(output.contains("expected text"));
}
Running Examples
cargo run --example hello
cargo run --example counter
cargo run --example todo_app
cargo run --example tree_demo
cargo run --example modal_demo
cargo run --example notification_demo
cargo run --example textarea_demo
cargo run --example file_picker_demo
cargo run --example theme_demo
cargo run --example typed_cmd_demo
cargo run --example macros_demo
cargo run --example streaming_demo
cargo run --example glm_chat
Architecture
src/
├── animation/ # Spring animations
├── cmd/ # Command system (Cmd, TypedCmd, executor)
├── components/ # 40+ UI components
├── core/ # Element, Style, Color primitives
├── hooks/ # React-like hooks
├── layout/ # Taffy-based flexbox layout engine
├── macros.rs # Declarative UI macros
├── renderer/ # Terminal rendering, App runner
├── runtime/ # Signal handling, environment detection
└── testing/ # Test utilities
Comparison with Ink/Bubbletea
| Feature |
rnk |
Ink |
Bubbletea |
| Language |
Rust |
JavaScript |
Go |
| Rendering |
Line-level diff |
Line-level diff |
Line-level diff |
| Layout |
Flexbox (Taffy) |
Flexbox (Yoga) |
Manual |
| State |
Hooks + Signals |
React hooks |
Model-Update |
| Type-safe Cmds |
TypedCmd |
N/A |
N/A |
| Declarative Macros |
row!/col!/text! |
JSX |
N/A |
| Components |
40+ |
~10 |
Bubbles lib |
| Inline mode |
✓ |
✓ |
✓ |
| Fullscreen |
✓ |
✓ |
✓ |
| Mouse support |
✓ |
✗ |
✓ |
| Bracketed paste |
✓ |
✗ |
✓ |
| Theme system |
✓ |
✗ |
Lipgloss |
| Println |
✓ |
Static |
tea.Println |
| Cross-thread |
request_render() |
- |
tea.Program.Send |
| Suspend/Resume |
✓ |
✗ |
✓ |
License
MIT