| Crates.io | rnk |
| lib.rs | rnk |
| version | 0.6.13 |
| created_at | 2026-01-11 16:14:22.396607+00 |
| updated_at | 2026-01-25 07:00:58.384128+00 |
| description | A React-like declarative terminal UI framework for Rust, inspired by Ink |
| homepage | https://github.com/majiayu000/rnk |
| repository | https://github.com/majiayu000/rnk |
| max_upload_size | |
| id | 2036031 |
| size | 627,058 |
A React-like declarative terminal UI framework for Rust, inspired by Ink and Bubbletea.
use_signal, use_effect, use_input, use_cmd)println() API for messages that persist above the UIrequest_render() for async/multi-threaded appsAdd to your Cargo.toml:
[dependencies]
rnk = "0.6"
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()
}
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()
}
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()
}
Output appears at current cursor position and persists in terminal history.
render(app).run()?; // Inline mode (default)
render(app).inline().run()?; // Explicit inline mode
Uses alternate screen buffer. Content is cleared on exit.
render(app).fullscreen().run()?;
render(app)
.fullscreen() // Use alternate screen
.fps(30) // Target 30 FPS (default: 60)
.exit_on_ctrl_c(false) // Handle Ctrl+C manually
.run()?;
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(); // Switch to inline
} else {
rnk::enter_alt_screen(); // Switch to fullscreen
}
}
});
// Run interactive TUI application
render(app).run()?;
Render elements to string without running the event loop:
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);
Flexbox container with full layout support.
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:
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)
Styled text with colors and formatting.
Text::new("Hello, World!")
.color(Color::Green)
.background_color(Color::Black)
.bold()
.italic()
.underline()
.strikethrough()
.dim()
.into_element()
Rich Text with Spans:
Text::builder()
.span("Normal ")
.span_styled("bold", |s| s.bold())
.span(" and ")
.span_styled("colored", |s| s.color(Color::Cyan))
.build()
.into_element()
Selectable list with keyboard navigation.
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()
Data table with headers and styling.
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()
Tab navigation component.
Tabs::new()
.tabs(vec!["Home", "Settings", "About"])
.selected(current_tab)
.on_change(|idx| { /* handle tab change */ })
.into_element()
Progress bars and gauges.
// 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()
Inline data visualization.
Sparkline::new()
.data(&[1, 3, 7, 2, 5, 8, 4])
.width(20)
.into_element()
Horizontal and vertical bar charts.
BarChart::new()
.data(&[("A", 10), ("B", 20), ("C", 15)])
.bar_width(3)
.bar_gap(1)
.into_element()
Permanent output that persists above dynamic UI.
Static::new(
items.to_vec(),
|item, index| {
Text::new(format!("[{}] {}", index + 1, item))
.color(Color::Gray)
.into_element()
}
).into_element()
Transform child text content.
Transform::new(|s| s.to_uppercase())
.child(Text::new("will be uppercase").into_element())
.into_element()
Layout helpers.
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()
Animated loading indicator.
Spinner::new()
.style(SpinnerStyle::Dots)
.label("Loading...")
.into_element()
Styled message boxes for info, success, warning, error.
Message::info("Information message")
Message::success("Operation completed!")
Message::warning("Please be careful")
Message::error("Something went wrong")
Reactive state management.
let count = use_signal(|| 0);
// Read value
let value = count.get();
// Update value
count.set(value + 1);
// Update with function
count.update(|v| *v += 1);
Side effects with dependencies.
let data = use_signal(|| Vec::new());
use_effect(
move || {
// Effect runs when dependencies change
println!("Data loaded: {:?}", data.get());
// Optional cleanup
Some(Box::new(|| {
println!("Cleanup");
}))
},
vec![data.get().len()], // Dependencies
);
Keyboard input handling.
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_arrowpage_up, page_down, home, endreturn_key, escape, tab, backspace, deletectrl, shift, alt (modifier keys)Mouse event handling.
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 */ }
_ => {}
}
});
Focus management for form inputs.
let focus_state = use_focus(UseFocusOptions {
auto_focus: true,
is_active: true,
id: None,
});
if focus_state.is_focused {
// Component is focused
}
Scroll state management.
let scroll = use_scroll();
// Configure content and viewport sizes
scroll.set_content_size(100, 500);
scroll.set_viewport_size(80, 20);
use_input(move |_input, key| {
if key.up_arrow {
scroll.scroll_up(1);
} else if key.down_arrow {
scroll.scroll_down(1);
} else if key.page_up {
scroll.page_up();
} else if key.page_down {
scroll.page_down();
}
});
// Get current scroll position
let offset_y = scroll.offset_y();
Application control.
let app = use_app();
use_input(move |input, _key| {
if input == "q" {
app.exit(); // Exit the application
}
});
Elm-inspired command system for side effects (async tasks, timers, file I/O).
use rnk::prelude::*;
use rnk::cmd::Cmd;
use std::time::Duration;
fn app() -> Element {
let status = use_signal(|| "Ready".to_string());
let data = use_signal(|| None::<String>);
// Run command when status changes
use_cmd(status.get(), move |_| {
Cmd::batch(vec![
// Delay for 1 second
Cmd::sleep(Duration::from_secs(1)),
// Then perform async task
Cmd::perform(
async {
// Simulate async work
"Data loaded!".to_string()
},
move |result| {
data.set(Some(result));
},
),
])
});
Box::new()
.child(Text::new(format!("Status: {}", status.get())).into_element())
.child(Text::new(format!("Data: {:?}", data.get())).into_element())
.into_element()
}
Available Commands:
// 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::perform(async { /* work */ }, |result| { /* handle result */ })
// Chain commands
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
Cmd::spawn("ls", vec!["-la"], |output| { /* handle output */ })
Set terminal window title.
use_window_title("My TUI App");
When updating state from background threads:
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()
}
Print persistent messages above the UI (inline mode only):
// 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);
// 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 }
rnk provides testing utilities for verifying UI components:
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"));
}
# 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
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
| 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 | React hooks | Model-Update |
| Inline mode | ✓ | ✓ | ✓ |
| Fullscreen | ✓ | ✓ | ✓ |
| Println | ✓ | Static component | tea.Println |
| Cross-thread | request_render() | - | tea.Program.Send |
MIT