A simple TUI Library written in Rust
at main 332 lines 11 kB view raw view rendered
1# SLY 2 3An 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. 4 5``` 6⠋ Building... 7[████████████░░░░░░░░] 60% 8``` 9 10## Features 11 12- **Inline rendering** — no alternate screen takeover; output stays in normal scrollback 13- **Pure/IO separation** — blocks and widgets are pure data; only `View::create/update` touches the terminal 14- **Interactive widgets** — text input, selector, multi-select, text area, confirm dialogs, button groups 15- **Multi-step flows** — chain interactive components into type-safe wizards 16- **Focus groups** — multiple widgets on screen simultaneously with Tab navigation 17- **Display widgets** — progress bars, spinners, status trees, tables, log panels 18- **Layout helpers** — padding, alignment, constraints, flex/auto/fixed column tables 19- **Theme system** — semantic color tokens with default, dark, and light variants 20- **Diff engine** — partial line redraws for flicker-free high-frequency updates 21- **221 tests**, zero unsafe code beyond `libc` FFI for terminal sizing 22 23## Quick start 24 25```toml 26[dependencies] 27sly = { path = "." } 28``` 29 30### Display-only (no interaction) 31 32```rust 33use sly::block::Block; 34use sly::style::{Span, Style}; 35use sly::color::Color; 36use sly::view::View; 37use sly::widgets::{spinner::Spinner, progress::ProgressBar, tree}; 38 39// Build a block (pure, no IO) 40let spinner = Spinner::dots().label("Building..."); 41let bar = ProgressBar::new(100).width(24).set(65); 42let block = Block::vstack(vec![spinner.frame(0), bar.to_block()], 0); 43 44// Render it inline (creates the view at the current cursor position) 45let width = 40; 46let mut view = View::create(&block, width); 47 48// Redraw in place 49let updated = Block::vstack(vec![spinner.frame(1), bar.clone().set(80).to_block()], 0); 50view.update(&updated); 51 52// Show cursor and leave the output in scrollback 53view.finish(); 54``` 55 56### Interactive widgets 57 58```rust 59use sly::input; 60use sly::widgets::text_input::{text_input, TextInput}; 61 62let result = input::with_raw_mode(|mode| { 63 text_input( 64 TextInput::new() 65 .prompt("Name: ") 66 .placeholder("Enter your name...") 67 ) 68 .run(mode) 69})?; 70 71match result { 72 Ok(name) => println!("Hello, {name}!"), 73 Err(_) => println!("Cancelled"), 74} 75``` 76 77### Multi-step flow 78 79Chain components into a sequential wizard. Each step's result feeds the next: 80 81```rust 82use sly::flow::Flow; 83use sly::input; 84use sly::widgets::{ 85 text_input::{text_input, TextInput}, 86 selector::{selector, Selector, SelectorItem}, 87 confirm::confirm, 88}; 89use sly::style::Span; 90 91let items = vec![ 92 SelectorItem::new("Admin", "admin"), 93 SelectorItem::new("Viewer", "viewer"), 94]; 95 96let flow = Flow::step( 97 text_input(TextInput::new().prompt("Username: ")), 98 move |name: String| { 99 Flow::step( 100 selector(Selector::new(items)), 101 move |role: &'static str| { 102 let msg = vec![Span::plain(format!("Create '{}' as {}?", name, role))]; 103 let name2 = name.clone(); 104 Flow::step(confirm(msg, true), move |ok| { 105 Flow::done(ok.then(|| (name2.clone(), role.to_string()))) 106 }) 107 }, 108 ) 109 }, 110); 111 112let result = input::with_raw_mode(|mode| flow.run(mode))??; 113``` 114 115### Animated spinner with `run_animated` 116 117```rust 118use sly::input; 119use sly::widgets::{ 120 spinner::Spinner, 121 progress::ProgressBar, 122}; 123use sly::block::Block; 124use sly::component::Update; 125 126#[derive(Clone)] 127struct State { tick: usize, progress: u64 } 128 129let spinner = Spinner::dots(); 130let bar = ProgressBar::new(20).width(20); 131 132input::with_raw_mode(|mode| { 133 sly::component::Component::new( 134 State { tick: 0, progress: 0 }, 135 move |s| Block::vstack(vec![ 136 spinner.frame(s.tick).clone(), 137 bar.clone().set(s.progress).to_block(), 138 ], 0), 139 |s, key| match key { 140 sly::input::Key::Escape => Update::Cancel, 141 _ => Update::Ignore, 142 }, 143 ) 144 .run_animated(mode, 80, |s| State { 145 tick: s.tick + 1, 146 progress: (s.progress + 1).min(20), 147 }) 148})?; 149``` 150 151## Widgets 152 153### Display (pure `Block` builders) 154 155| Widget | Description | 156|--------|-------------| 157| `widgets::progress::ProgressBar` | Progress bar — `Bar`, `Braille`, `AsciiBar` styles | 158| `widgets::spinner::Spinner` | Animated spinner — `dots`, `braille`, `arc`, `arrows`, `bounce`, `line` | 159| `widgets::tree` | Status tree with connector lines — `Pending/Running/Done/Failed/Skipped` | 160| `widgets::table::Table` | Table with `Fixed`/`Auto`/`Flex` column widths, optional border | 161| `widgets::log::LogPanel` | Sliding-window log panel — shows last N lines | 162| `widgets::checklist::Checklist` | Updatable checklist — `Pending/InProgress/Done/Failed/Skipped` with inline notes | 163 164### Interactive (`Component<S, R>`) 165 166| Widget | Returns | Keys | 167|--------|---------|------| 168| `widgets::text_input::text_input` | `String` | Type, Backspace/Delete, Home/End, Ctrl+W/U, Enter | 169| `widgets::number_input::number_input` | `f64` | Digits, Up/Down to increment, Enter | 170| `widgets::selector::selector` | `T` | Up/Down, Home/End, type-ahead search, Enter | 171| `widgets::grouped_selector::grouped_selector` | `T` | Up/Down skipping headers, type-ahead, Enter | 172| `widgets::multi_selector::multi_selector` | `Vec<T>` | Space toggle, Ctrl+A/D, Up/Down, Enter | 173| `widgets::autocomplete::autocomplete` | `T` | Type to filter, Up/Down in dropdown, Tab accept, Enter | 174| `widgets::text_area::text_area` | `String` | Type, Enter newline, Up/Down through wrapped lines, Ctrl+J submit, bracketed paste | 175| `widgets::log_view::log_view` | `()` | Up/Down/PgUp/PgDn/Home/End scroll, q/Enter exit | 176| `widgets::confirm::confirm` | `bool` | y/n, Left/Right, Enter | 177| `widgets::button::button` | `()` | Enter/Space | 178| `widgets::button::button_group` | `usize` (index) | Left/Right, Enter/Space | 179 180### Composition 181 182| Type | Description | 183|------|-------------| 184| `flow::Flow<R>` | Sequential multi-step composition — `Flow::step(component, continuation)` | 185| `focus::FocusGroup` | Multiple widgets on screen simultaneously — Tab/BackTab routing | 186| `focus::FocusSlot<S, R>` | Wrap a state machine as a `FocusItem`; retrieve result after group runs | 187 188## Layout 189 190```rust 191use sly::layout::{padded, Padding, align_in, Constraints, constrained}; 192use sly::style::Align; 193 194// Add padding 195let padded_block = padded(inner, Padding::xy(2, 1)); // 2 cols each side, 1 row each side 196let padded_block = padded(inner, Padding::all(1)); // equal all sides 197let padded_block = padded(inner, Padding::horizontal(4)); // left+right only 198 199// Align within a fixed width 200let centered = align_in(block, Align::Center, 80); 201 202// Min/max constraints 203let sized = constrained(block, Constraints { 204 min_width: Some(20), 205 max_width: Some(60), 206 ..Default::default() 207}); 208``` 209 210## Blocks 211 212Blocks are the core rendering primitive — pure values, composable, no IO: 213 214```rust 215use sly::block::{Block, BorderStyle}; 216use sly::style::{Span, Style}; 217use sly::color::Color; 218 219// Plain text 220let b = Block::text_plain("Hello, world!"); 221 222// Styled text 223let b = Block::text(vec![vec![ 224 Span::plain("Status: "), 225 Span::styled("OK", Style::new().bold().fg(Color::Green)), 226]]); 227 228// Bordered box with title 229let b = Block::boxed( 230 Block::text_plain("content"), 231 BorderStyle::Rounded, 232 Some(vec![Span::plain(" Title ")]), 233); 234 235// Compose 236let b = Block::vstack(vec![header, body, footer], 1); // gap=1 237let b = Block::hstack(vec![left, right], 2); // gap=2 238``` 239 240Border styles: `Single` `Double` `Rounded` `Thick` `Ascii` `NoBorder` 241 242## Theme 243 244```rust 245use sly::theme::Theme; 246 247let t = Theme::default_theme(); // neutral 248let t = Theme::dark(); // high contrast 249let t = Theme::light(); // light backgrounds 250 251// Semantic style helpers 252let style = t.primary_style(); // bold + primary color 253let style = t.success_style(); 254let style = t.error_style(); 255let style = t.muted_style(); 256``` 257 258## Architecture 259 260``` 261src/ 262├── ansi.rs — raw escape sequence builders (pure) 263├── color.rs — Color enum: 16 named, 256-palette, RGB (pure) 264├── style.rs — Style, Span, Line types (pure) 265├── block.rs — Block enum + rendering engine (pure) 266├── wrap.rs — word/char/no-wrap text engine (pure) 267├── layout.rs — padding, alignment, constraints (pure) 268├── diff.rs — line-diff for partial redraws (pure) 269├── theme.rs — Theme type with semantic color tokens (pure) 270├── terminal.rs — size / TTY / color-level detection (IO) 271├── input.rs — raw mode, Key decoding (IO) 272├── view.rs — inline render/update/erase lifecycle (IO) 273├── component.rs — Component<S,R> state machine + event loop (IO) 274├── focus.rs — FocusGroup, Tab routing (IO) 275├── flow.rs — sequential multi-step composition (IO) 276└── widgets/ 277 ├── progress.rs 278 ├── spinner.rs 279 ├── tree.rs 280 ├── button.rs 281 ├── selector.rs 282 ├── text_input.rs 283 ├── multi_selector.rs 284 ├── text_area.rs 285 ├── confirm.rs 286 ├── table.rs 287 └── log.rs 288``` 289 290**Key invariants:** 291- Pure modules (`ansi` through `theme`) have zero IO — they are trivially testable by construction 292- 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 293- All styled spans end with `ansi::reset()` — no style leakage across boundaries 294- No global state — themes and config are passed through function arguments 295 296## Additional features 297 298| Feature | API | 299|---------|-----| 300| OSC 8 hyperlinks | `ansi::hyperlink(url, text) -> String` | 301| Bracketed paste | `input::enable_bracketed_paste(mode)` + `Key::Paste(String)` | 302| Terminal resize | Auto-handled in `Component::run` via SIGWINCH | 303| Async updates | `channel::channel(capacity)` + `Component::run_with_channel(mode, rx, on_msg)` | 304 305## Running the demo 306 307``` 308cargo run --example demo 309``` 310 311The demo walks through all static and interactive widgets in sequence. 312 313## Running tests 314 315``` 316cargo test # 221 library tests 317cargo test --example demo # 38 demo helper tests 318``` 319 320## Contributing 321 322After cloning, activate the pre-push hook that enforces formatting: 323 324``` 325git config core.hooksPath .hooks 326``` 327 328This runs `cargo fmt --check` before every push. To fix formatting locally: 329 330``` 331cargo fmt 332```