# 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 ```toml [dependencies] sly = { path = "." } ``` ### Display-only (no interaction) ```rust 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 ```rust 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: ```rust 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` ```rust 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`) | 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` | 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` | Sequential multi-step composition — `Flow::step(component, continuation)` | | `focus::FocusGroup` | Multiple widgets on screen simultaneously — Tab/BackTab routing | | `focus::FocusSlot` | Wrap a state machine as a `FocusItem`; retrieve result after group runs | ## Layout ```rust 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: ```rust 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 ```rust 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 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`) + 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 ```