A simple TUI Library written in Rust
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```