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/updatetouches 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
libcFFI 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 (
ansithroughtheme) 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::runis 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