A simple TUI Library written in Rust

SLY#

An inline/relative ANSI TUI library for Rust. Unlike full-screen TUI libraries (ratatui, bubbletea), SLY anchors rendering to the current cursor position and redraws in place, preserving terminal scrollback. Think cargo build status trees or npm install progress blocks.

⠋ Building...
[████████████░░░░░░░░] 60%

Features#

  • Inline rendering — no alternate screen takeover; output stays in normal scrollback
  • Pure/IO separation — blocks and widgets are pure data; only View::create/update touches the terminal
  • Interactive widgets — text input, selector, multi-select, text area, confirm dialogs, button groups
  • Multi-step flows — chain interactive components into type-safe wizards
  • Focus groups — multiple widgets on screen simultaneously with Tab navigation
  • Display widgets — progress bars, spinners, status trees, tables, log panels
  • Layout helpers — padding, alignment, constraints, flex/auto/fixed column tables
  • Theme system — semantic color tokens with default, dark, and light variants
  • Diff engine — partial line redraws for flicker-free high-frequency updates
  • 221 tests, zero unsafe code beyond libc FFI for terminal sizing

Quick start#

[dependencies]
sly = { path = "." }

Display-only (no interaction)#

use sly::block::Block;
use sly::style::{Span, Style};
use sly::color::Color;
use sly::view::View;
use sly::widgets::{spinner::Spinner, progress::ProgressBar, tree};

// Build a block (pure, no IO)
let spinner = Spinner::dots().label("Building...");
let bar = ProgressBar::new(100).width(24).set(65);
let block = Block::vstack(vec![spinner.frame(0), bar.to_block()], 0);

// Render it inline (creates the view at the current cursor position)
let width = 40;
let mut view = View::create(&block, width);

// Redraw in place
let updated = Block::vstack(vec![spinner.frame(1), bar.clone().set(80).to_block()], 0);
view.update(&updated);

// Show cursor and leave the output in scrollback
view.finish();

Interactive widgets#

use sly::input;
use sly::widgets::text_input::{text_input, TextInput};

let result = input::with_raw_mode(|mode| {
    text_input(
        TextInput::new()
            .prompt("Name: ")
            .placeholder("Enter your name...")
    )
    .run(mode)
})?;

match result {
    Ok(name) => println!("Hello, {name}!"),
    Err(_)   => println!("Cancelled"),
}

Multi-step flow#

Chain components into a sequential wizard. Each step's result feeds the next:

use sly::flow::Flow;
use sly::input;
use sly::widgets::{
    text_input::{text_input, TextInput},
    selector::{selector, Selector, SelectorItem},
    confirm::confirm,
};
use sly::style::Span;

let items = vec![
    SelectorItem::new("Admin",  "admin"),
    SelectorItem::new("Viewer", "viewer"),
];

let flow = Flow::step(
    text_input(TextInput::new().prompt("Username: ")),
    move |name: String| {
        Flow::step(
            selector(Selector::new(items)),
            move |role: &'static str| {
                let msg = vec![Span::plain(format!("Create '{}' as {}?", name, role))];
                let name2 = name.clone();
                Flow::step(confirm(msg, true), move |ok| {
                    Flow::done(ok.then(|| (name2.clone(), role.to_string())))
                })
            },
        )
    },
);

let result = input::with_raw_mode(|mode| flow.run(mode))??;

Animated spinner with run_animated#

use sly::input;
use sly::widgets::{
    spinner::Spinner,
    progress::ProgressBar,
};
use sly::block::Block;
use sly::component::Update;

#[derive(Clone)]
struct State { tick: usize, progress: u64 }

let spinner = Spinner::dots();
let bar = ProgressBar::new(20).width(20);

input::with_raw_mode(|mode| {
    sly::component::Component::new(
        State { tick: 0, progress: 0 },
        move |s| Block::vstack(vec![
            spinner.frame(s.tick).clone(),
            bar.clone().set(s.progress).to_block(),
        ], 0),
        |s, key| match key {
            sly::input::Key::Escape => Update::Cancel,
            _ => Update::Ignore,
        },
    )
    .run_animated(mode, 80, |s| State {
        tick: s.tick + 1,
        progress: (s.progress + 1).min(20),
    })
})?;

Widgets#

Display (pure Block builders)#

Widget Description
widgets::progress::ProgressBar Progress bar — Bar, Braille, AsciiBar styles
widgets::spinner::Spinner Animated spinner — dots, braille, arc, arrows, bounce, line
widgets::tree Status tree with connector lines — Pending/Running/Done/Failed/Skipped
widgets::table::Table Table with Fixed/Auto/Flex column widths, optional border
widgets::log::LogPanel Sliding-window log panel — shows last N lines
widgets::checklist::Checklist Updatable checklist — Pending/InProgress/Done/Failed/Skipped with inline notes

Interactive (Component<S, R>)#

Widget Returns Keys
widgets::text_input::text_input String Type, Backspace/Delete, Home/End, Ctrl+W/U, Enter
widgets::number_input::number_input f64 Digits, Up/Down to increment, Enter
widgets::selector::selector T Up/Down, Home/End, type-ahead search, Enter
widgets::grouped_selector::grouped_selector T Up/Down skipping headers, type-ahead, Enter
widgets::multi_selector::multi_selector Vec<T> Space toggle, Ctrl+A/D, Up/Down, Enter
widgets::autocomplete::autocomplete T Type to filter, Up/Down in dropdown, Tab accept, Enter
widgets::text_area::text_area String Type, Enter newline, Up/Down through wrapped lines, Ctrl+J submit, bracketed paste
widgets::log_view::log_view () Up/Down/PgUp/PgDn/Home/End scroll, q/Enter exit
widgets::confirm::confirm bool y/n, Left/Right, Enter
widgets::button::button () Enter/Space
widgets::button::button_group usize (index) Left/Right, Enter/Space

Composition#

Type Description
flow::Flow<R> Sequential multi-step composition — Flow::step(component, continuation)
focus::FocusGroup Multiple widgets on screen simultaneously — Tab/BackTab routing
focus::FocusSlot<S, R> Wrap a state machine as a FocusItem; retrieve result after group runs

Layout#

use sly::layout::{padded, Padding, align_in, Constraints, constrained};
use sly::style::Align;

// Add padding
let padded_block = padded(inner, Padding::xy(2, 1));     // 2 cols each side, 1 row each side
let padded_block = padded(inner, Padding::all(1));        // equal all sides
let padded_block = padded(inner, Padding::horizontal(4)); // left+right only

// Align within a fixed width
let centered = align_in(block, Align::Center, 80);

// Min/max constraints
let sized = constrained(block, Constraints {
    min_width: Some(20),
    max_width: Some(60),
    ..Default::default()
});

Blocks#

Blocks are the core rendering primitive — pure values, composable, no IO:

use sly::block::{Block, BorderStyle};
use sly::style::{Span, Style};
use sly::color::Color;

// Plain text
let b = Block::text_plain("Hello, world!");

// Styled text
let b = Block::text(vec![vec![
    Span::plain("Status: "),
    Span::styled("OK", Style::new().bold().fg(Color::Green)),
]]);

// Bordered box with title
let b = Block::boxed(
    Block::text_plain("content"),
    BorderStyle::Rounded,
    Some(vec![Span::plain(" Title ")]),
);

// Compose
let b = Block::vstack(vec![header, body, footer], 1); // gap=1
let b = Block::hstack(vec![left, right], 2);           // gap=2

Border styles: Single Double Rounded Thick Ascii NoBorder

Theme#

use sly::theme::Theme;

let t = Theme::default_theme(); // neutral
let t = Theme::dark();          // high contrast
let t = Theme::light();         // light backgrounds

// Semantic style helpers
let style = t.primary_style();  // bold + primary color
let style = t.success_style();
let style = t.error_style();
let style = t.muted_style();

Architecture#

src/
├── ansi.rs         — raw escape sequence builders (pure)
├── color.rs        — Color enum: 16 named, 256-palette, RGB (pure)
├── style.rs        — Style, Span, Line types (pure)
├── block.rs        — Block enum + rendering engine (pure)
├── wrap.rs         — word/char/no-wrap text engine (pure)
├── layout.rs       — padding, alignment, constraints (pure)
├── diff.rs         — line-diff for partial redraws (pure)
├── theme.rs        — Theme type with semantic color tokens (pure)
├── terminal.rs     — size / TTY / color-level detection (IO)
├── input.rs        — raw mode, Key decoding (IO)
├── view.rs         — inline render/update/erase lifecycle (IO)
├── component.rs    — Component<S,R> state machine + event loop (IO)
├── focus.rs        — FocusGroup, Tab routing (IO)
├── flow.rs         — sequential multi-step composition (IO)
└── widgets/
    ├── progress.rs
    ├── spinner.rs
    ├── tree.rs
    ├── button.rs
    ├── selector.rs
    ├── text_input.rs
    ├── multi_selector.rs
    ├── text_area.rs
    ├── confirm.rs
    ├── table.rs
    └── log.rs

Key invariants:

  • Pure modules (ansi through theme) have zero IO — they are trivially testable by construction
  • Every interactive widget is a pure state machine (update: fn(&S, &Key) -> Update<S, R>) + a pure renderer (render: fn(&S) -> Block); Component::run is the only IO piece
  • All styled spans end with ansi::reset() — no style leakage across boundaries
  • No global state — themes and config are passed through function arguments

Additional features#

Feature API
OSC 8 hyperlinks ansi::hyperlink(url, text) -> String
Bracketed paste input::enable_bracketed_paste(mode) + Key::Paste(String)
Terminal resize Auto-handled in Component::run via SIGWINCH
Async updates channel::channel(capacity) + Component::run_with_channel(mode, rx, on_msg)

Running the demo#

cargo run --example demo

The demo walks through all static and interactive widgets in sequence.

Running tests#

cargo test          # 221 library tests
cargo test --example demo  # 38 demo helper tests

Contributing#

After cloning, activate the pre-push hook that enforces formatting:

git config core.hooksPath .hooks

This runs cargo fmt --check before every push. To fix formatting locally:

cargo fmt