rnk

Crates.iornk
lib.rsrnk
version0.6.13
created_at2026-01-11 16:14:22.396607+00
updated_at2026-01-25 07:00:58.384128+00
descriptionA React-like declarative terminal UI framework for Rust, inspired by Ink
homepagehttps://github.com/majiayu000/rnk
repositoryhttps://github.com/majiayu000/rnk
max_upload_size
id2036031
size627,058
lif (majiayu000)

documentation

https://docs.rs/rnk

README

rnk

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

Crates.io Documentation License: MIT

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 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:

[dependencies]
rnk = "0.6"

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()
}

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()
}

Streaming Output Demo

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.

render(app).run()?;           // Inline mode (default)
render(app).inline().run()?;  // Explicit inline mode

Fullscreen Mode

Uses alternate screen buffer. Content is cleared on exit.

render(app).fullscreen().run()?;

Configuration Options

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:

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
        }
    }
});

Render APIs

Interactive Applications

// Run interactive TUI application
render(app).run()?;

Static Rendering (Non-interactive)

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);

Components

Box

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)

Text

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()

List

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()

Table

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()

Tabs

Tab navigation component.

Tabs::new()
    .tabs(vec!["Home", "Settings", "About"])
    .selected(current_tab)
    .on_change(|idx| { /* handle tab change */ })
    .into_element()

Progress / Gauge

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()

Sparkline

Inline data visualization.

Sparkline::new()
    .data(&[1, 3, 7, 2, 5, 8, 4])
    .width(20)
    .into_element()

BarChart

Horizontal and vertical bar charts.

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.

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.

Transform::new(|s| s.to_uppercase())
    .child(Text::new("will be uppercase").into_element())
    .into_element()

Spacer / Newline

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()

Spinner

Animated loading indicator.

Spinner::new()
    .style(SpinnerStyle::Dots)
    .label("Loading...")
    .into_element()

Message

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")

Hooks

use_signal

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);

use_effect

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
);

use_input

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_arrow
  • page_up, page_down, home, end
  • return_key, escape, tab, backspace, delete
  • ctrl, shift, alt (modifier keys)

use_mouse

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 */ }
        _ => {}
    }
});

use_focus

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
}

use_scroll

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();

use_app

Application control.

let app = use_app();

use_input(move |input, _key| {
    if input == "q" {
        app.exit();  // Exit the application
    }
});

use_cmd

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 */ })

use_window_title

Set terminal window title.

use_window_title("My TUI App");

Cross-thread Rendering

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()
}

Println API

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);

Colors

// 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:

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

# 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

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

License

MIT

Commit count: 59

cargo fmt