Skip to main content

slt/
lib.rs

1//! # SLT — Super Light TUI
2//!
3//! Immediate-mode terminal UI for Rust. Two dependencies. Zero `unsafe`.
4//!
5//! SLT gives you an egui-style API for terminals: your closure runs each frame,
6//! you describe your UI, and SLT handles layout, diffing, and rendering.
7//!
8//! ## Quick Start
9//!
10//! ```no_run
11//! fn main() -> std::io::Result<()> {
12//!     slt::run(|ui| {
13//!         ui.text("hello, world");
14//!     })
15//! }
16//! ```
17//!
18//! ## Features
19//!
20//! - **Flexbox layout** — `row()`, `col()`, `gap()`, `grow()`
21//! - **14 built-in widgets** — input, textarea, table, list, tabs, button, checkbox, toggle, spinner, progress, toast, separator, help bar, scrollable
22//! - **Styling** — bold, italic, dim, underline, 256 colors, RGB
23//! - **Mouse** — click, hover, drag-to-scroll
24//! - **Focus** — automatic Tab/Shift+Tab cycling
25//! - **Theming** — dark/light presets or custom
26//! - **Animation** — tween and spring primitives with 9 easing functions
27//! - **Inline mode** — render below your prompt, no alternate screen
28//! - **Async** — optional tokio integration via `async` feature
29//! - **Layout debugger** — F12 to visualize container bounds
30//!
31//! ## Feature Flags
32//!
33//! | Flag | Description |
34//! |------|-------------|
35//! | `async` | Enable `run_async()` with tokio channel-based message passing |
36
37pub mod anim;
38pub mod buffer;
39pub mod cell;
40pub mod context;
41pub mod event;
42pub mod layout;
43pub mod rect;
44pub mod style;
45mod terminal;
46pub mod test_utils;
47pub mod widgets;
48
49use std::io;
50use std::sync::Once;
51use std::time::Duration;
52
53use event::Event;
54use terminal::{InlineTerminal, Terminal};
55
56pub use crate::test_utils::TestBackend;
57pub use anim::{Spring, Tween};
58pub use context::{Context, Response, Widget};
59pub use event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseKind};
60pub use style::{Align, Border, Color, Constraints, Margin, Modifiers, Padding, Style, Theme};
61pub use widgets::{
62    ListState, ScrollState, SpinnerState, TableState, TabsState, TextInputState, TextareaState,
63    ToastLevel, ToastMessage, ToastState,
64};
65
66static PANIC_HOOK_ONCE: Once = Once::new();
67
68fn install_panic_hook() {
69    PANIC_HOOK_ONCE.call_once(|| {
70        let original = std::panic::take_hook();
71        std::panic::set_hook(Box::new(move |panic_info| {
72            let _ = crossterm::terminal::disable_raw_mode();
73            let mut stdout = io::stdout();
74            let _ = crossterm::execute!(
75                stdout,
76                crossterm::terminal::LeaveAlternateScreen,
77                crossterm::cursor::Show,
78                crossterm::event::DisableMouseCapture,
79                crossterm::style::ResetColor,
80                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
81            );
82            original(panic_info);
83        }));
84    });
85}
86
87/// Configuration for a TUI run loop.
88///
89/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
90/// Use [`Default::default()`] for sensible defaults (100ms tick, no mouse, dark theme).
91///
92/// # Example
93///
94/// ```no_run
95/// use slt::{RunConfig, Theme};
96/// use std::time::Duration;
97///
98/// let config = RunConfig {
99///     tick_rate: Duration::from_millis(50),
100///     mouse: true,
101///     theme: Theme::light(),
102/// };
103/// ```
104pub struct RunConfig {
105    /// How long to wait for input before triggering a tick with no events.
106    ///
107    /// Lower values give smoother animations at the cost of more CPU usage.
108    /// Defaults to 100ms.
109    pub tick_rate: Duration,
110    /// Whether to enable mouse event reporting.
111    ///
112    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
113    /// Defaults to `false`.
114    pub mouse: bool,
115    /// The color theme applied to all widgets automatically.
116    ///
117    /// Defaults to [`Theme::dark()`].
118    pub theme: Theme,
119}
120
121impl Default for RunConfig {
122    fn default() -> Self {
123        Self {
124            tick_rate: Duration::from_millis(100),
125            mouse: false,
126            theme: Theme::dark(),
127        }
128    }
129}
130
131/// Run the TUI loop with default configuration.
132///
133/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
134/// Ctrl+C or when [`Context::quit`] is called.
135///
136/// # Example
137///
138/// ```no_run
139/// fn main() -> std::io::Result<()> {
140///     slt::run(|ui| {
141///         ui.text("Press Ctrl+C to exit");
142///     })
143/// }
144/// ```
145pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
146    run_with(RunConfig::default(), f)
147}
148
149/// Run the TUI loop with custom configuration.
150///
151/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
152/// support, and theming.
153///
154/// # Example
155///
156/// ```no_run
157/// use slt::{RunConfig, Theme};
158///
159/// fn main() -> std::io::Result<()> {
160///     slt::run_with(
161///         RunConfig { theme: Theme::light(), ..Default::default() },
162///         |ui| {
163///             ui.text("Light theme!");
164///         },
165///     )
166/// }
167/// ```
168pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
169    install_panic_hook();
170    let mut term = Terminal::new(config.mouse)?;
171    let mut events: Vec<Event> = Vec::new();
172    let mut debug_mode: bool = false;
173    let mut tick: u64 = 0;
174    let mut focus_index: usize = 0;
175    let mut prev_focus_count: usize = 0;
176    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
177    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
178    let mut last_mouse_pos: Option<(u32, u32)> = None;
179
180    loop {
181        let (w, h) = term.size();
182        if w == 0 || h == 0 {
183            continue;
184        }
185        let mut ctx = Context::new(
186            std::mem::take(&mut events),
187            w,
188            h,
189            tick,
190            focus_index,
191            prev_focus_count,
192            std::mem::take(&mut prev_scroll_infos),
193            std::mem::take(&mut prev_hit_map),
194            debug_mode,
195            config.theme,
196            last_mouse_pos,
197        );
198        ctx.process_focus_keys();
199
200        f(&mut ctx);
201
202        if ctx.should_quit {
203            break;
204        }
205
206        focus_index = ctx.focus_index;
207        prev_focus_count = ctx.focus_count;
208
209        let mut tree = layout::build_tree(&ctx.commands);
210        let area = crate::rect::Rect::new(0, 0, w, h);
211        layout::compute(&mut tree, area);
212        prev_scroll_infos = layout::collect_scroll_infos(&tree);
213        prev_hit_map = layout::collect_hit_areas(&tree);
214        layout::render(&tree, term.buffer_mut());
215        if debug_mode {
216            layout::render_debug_overlay(&tree, term.buffer_mut());
217        }
218
219        term.flush()?;
220        tick = tick.wrapping_add(1);
221
222        events.clear();
223        if crossterm::event::poll(config.tick_rate)? {
224            let raw = crossterm::event::read()?;
225            if let Some(ev) = event::from_crossterm(raw) {
226                if is_ctrl_c(&ev) {
227                    break;
228                }
229                if let Event::Resize(_, _) = &ev {
230                    term.handle_resize()?;
231                }
232                events.push(ev);
233            }
234
235            while crossterm::event::poll(Duration::ZERO)? {
236                let raw = crossterm::event::read()?;
237                if let Some(ev) = event::from_crossterm(raw) {
238                    if is_ctrl_c(&ev) {
239                        return Ok(());
240                    }
241                    if let Event::Resize(_, _) = &ev {
242                        term.handle_resize()?;
243                    }
244                    events.push(ev);
245                }
246            }
247
248            for ev in &events {
249                if matches!(
250                    ev,
251                    Event::Key(event::KeyEvent {
252                        code: KeyCode::F(12),
253                        ..
254                    })
255                ) {
256                    debug_mode = !debug_mode;
257                }
258            }
259        }
260
261        for ev in &events {
262            if let Event::Mouse(mouse) = ev {
263                last_mouse_pos = Some((mouse.x, mouse.y));
264            }
265        }
266
267        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
268            prev_hit_map.clear();
269            prev_scroll_infos.clear();
270            last_mouse_pos = None;
271        }
272    }
273
274    Ok(())
275}
276
277/// Run the TUI loop asynchronously with default configuration.
278///
279/// Requires the `async` feature. Spawns the render loop in a blocking thread
280/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
281/// from async tasks into the UI closure.
282///
283/// # Example
284///
285/// ```no_run
286/// # #[cfg(feature = "async")]
287/// # async fn example() -> std::io::Result<()> {
288/// let tx = slt::run_async::<String>(|ui, messages| {
289///     for msg in messages.drain(..) {
290///         ui.text(msg);
291///     }
292/// })?;
293/// tx.send("hello from async".to_string()).await.ok();
294/// # Ok(())
295/// # }
296/// ```
297#[cfg(feature = "async")]
298pub fn run_async<M: Send + 'static>(
299    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
300) -> io::Result<tokio::sync::mpsc::Sender<M>> {
301    run_async_with(RunConfig::default(), f)
302}
303
304/// Run the TUI loop asynchronously with custom configuration.
305///
306/// Requires the `async` feature. Like [`run_async`], but accepts a
307/// [`RunConfig`] to control tick rate, mouse support, and theming.
308///
309/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
310#[cfg(feature = "async")]
311pub fn run_async_with<M: Send + 'static>(
312    config: RunConfig,
313    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
314) -> io::Result<tokio::sync::mpsc::Sender<M>> {
315    let (tx, rx) = tokio::sync::mpsc::channel(100);
316    let handle =
317        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
318
319    handle.spawn_blocking(move || {
320        let _ = run_async_loop(config, f, rx);
321    });
322
323    Ok(tx)
324}
325
326#[cfg(feature = "async")]
327fn run_async_loop<M: Send + 'static>(
328    config: RunConfig,
329    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
330    mut rx: tokio::sync::mpsc::Receiver<M>,
331) -> io::Result<()> {
332    install_panic_hook();
333    let mut term = Terminal::new(config.mouse)?;
334    let mut events: Vec<Event> = Vec::new();
335    let mut tick: u64 = 0;
336    let mut focus_index: usize = 0;
337    let mut prev_focus_count: usize = 0;
338    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
339    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
340    let mut last_mouse_pos: Option<(u32, u32)> = None;
341
342    loop {
343        let mut messages: Vec<M> = Vec::new();
344        while let Ok(message) = rx.try_recv() {
345            messages.push(message);
346        }
347
348        let (w, h) = term.size();
349        if w == 0 || h == 0 {
350            continue;
351        }
352        let mut ctx = Context::new(
353            std::mem::take(&mut events),
354            w,
355            h,
356            tick,
357            focus_index,
358            prev_focus_count,
359            std::mem::take(&mut prev_scroll_infos),
360            std::mem::take(&mut prev_hit_map),
361            false,
362            config.theme,
363            last_mouse_pos,
364        );
365        ctx.process_focus_keys();
366
367        f(&mut ctx, &mut messages);
368
369        if ctx.should_quit {
370            break;
371        }
372
373        focus_index = ctx.focus_index;
374        prev_focus_count = ctx.focus_count;
375
376        let mut tree = layout::build_tree(&ctx.commands);
377        let area = crate::rect::Rect::new(0, 0, w, h);
378        layout::compute(&mut tree, area);
379        prev_scroll_infos = layout::collect_scroll_infos(&tree);
380        prev_hit_map = layout::collect_hit_areas(&tree);
381        layout::render(&tree, term.buffer_mut());
382
383        term.flush()?;
384        tick = tick.wrapping_add(1);
385
386        events.clear();
387        if crossterm::event::poll(config.tick_rate)? {
388            let raw = crossterm::event::read()?;
389            if let Some(ev) = event::from_crossterm(raw) {
390                if is_ctrl_c(&ev) {
391                    break;
392                }
393                if let Event::Resize(_, _) = &ev {
394                    term.handle_resize()?;
395                    prev_hit_map.clear();
396                    prev_scroll_infos.clear();
397                    last_mouse_pos = None;
398                }
399                events.push(ev);
400            }
401
402            while crossterm::event::poll(Duration::ZERO)? {
403                let raw = crossterm::event::read()?;
404                if let Some(ev) = event::from_crossterm(raw) {
405                    if is_ctrl_c(&ev) {
406                        return Ok(());
407                    }
408                    if let Event::Resize(_, _) = &ev {
409                        term.handle_resize()?;
410                        prev_hit_map.clear();
411                        prev_scroll_infos.clear();
412                        last_mouse_pos = None;
413                    }
414                    events.push(ev);
415                }
416            }
417        }
418
419        for ev in &events {
420            if let Event::Mouse(mouse) = ev {
421                last_mouse_pos = Some((mouse.x, mouse.y));
422            }
423        }
424    }
425
426    Ok(())
427}
428
429/// Run the TUI in inline mode with default configuration.
430///
431/// Renders `height` rows directly below the current cursor position without
432/// entering alternate screen mode. Useful for CLI tools that want a small
433/// interactive widget below the prompt.
434///
435/// # Example
436///
437/// ```no_run
438/// fn main() -> std::io::Result<()> {
439///     slt::run_inline(3, |ui| {
440///         ui.text("Inline TUI — no alternate screen");
441///     })
442/// }
443/// ```
444pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
445    run_inline_with(height, RunConfig::default(), f)
446}
447
448/// Run the TUI in inline mode with custom configuration.
449///
450/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
451/// mouse support, and theming.
452pub fn run_inline_with(
453    height: u32,
454    config: RunConfig,
455    mut f: impl FnMut(&mut Context),
456) -> io::Result<()> {
457    install_panic_hook();
458    let mut term = InlineTerminal::new(height, config.mouse)?;
459    let mut events: Vec<Event> = Vec::new();
460    let mut debug_mode: bool = false;
461    let mut tick: u64 = 0;
462    let mut focus_index: usize = 0;
463    let mut prev_focus_count: usize = 0;
464    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
465    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
466    let mut last_mouse_pos: Option<(u32, u32)> = None;
467
468    loop {
469        let (w, h) = term.size();
470        if w == 0 || h == 0 {
471            continue;
472        }
473        let mut ctx = Context::new(
474            std::mem::take(&mut events),
475            w,
476            h,
477            tick,
478            focus_index,
479            prev_focus_count,
480            std::mem::take(&mut prev_scroll_infos),
481            std::mem::take(&mut prev_hit_map),
482            debug_mode,
483            config.theme,
484            last_mouse_pos,
485        );
486        ctx.process_focus_keys();
487
488        f(&mut ctx);
489
490        if ctx.should_quit {
491            break;
492        }
493
494        focus_index = ctx.focus_index;
495        prev_focus_count = ctx.focus_count;
496
497        let mut tree = layout::build_tree(&ctx.commands);
498        let area = crate::rect::Rect::new(0, 0, w, h);
499        layout::compute(&mut tree, area);
500        prev_scroll_infos = layout::collect_scroll_infos(&tree);
501        prev_hit_map = layout::collect_hit_areas(&tree);
502        layout::render(&tree, term.buffer_mut());
503        if debug_mode {
504            layout::render_debug_overlay(&tree, term.buffer_mut());
505        }
506
507        term.flush()?;
508        tick = tick.wrapping_add(1);
509
510        events.clear();
511        if crossterm::event::poll(config.tick_rate)? {
512            let raw = crossterm::event::read()?;
513            if let Some(ev) = event::from_crossterm(raw) {
514                if is_ctrl_c(&ev) {
515                    break;
516                }
517                if let Event::Resize(_, _) = &ev {
518                    term.handle_resize()?;
519                }
520                events.push(ev);
521            }
522
523            while crossterm::event::poll(Duration::ZERO)? {
524                let raw = crossterm::event::read()?;
525                if let Some(ev) = event::from_crossterm(raw) {
526                    if is_ctrl_c(&ev) {
527                        return Ok(());
528                    }
529                    if let Event::Resize(_, _) = &ev {
530                        term.handle_resize()?;
531                    }
532                    events.push(ev);
533                }
534            }
535
536            for ev in &events {
537                if matches!(
538                    ev,
539                    Event::Key(event::KeyEvent {
540                        code: KeyCode::F(12),
541                        ..
542                    })
543                ) {
544                    debug_mode = !debug_mode;
545                }
546            }
547        }
548
549        for ev in &events {
550            if let Event::Mouse(mouse) = ev {
551                last_mouse_pos = Some((mouse.x, mouse.y));
552            }
553        }
554
555        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
556            prev_hit_map.clear();
557            prev_scroll_infos.clear();
558            last_mouse_pos = None;
559        }
560    }
561
562    Ok(())
563}
564
565fn is_ctrl_c(ev: &Event) -> bool {
566    matches!(
567        ev,
568        Event::Key(event::KeyEvent {
569            code: KeyCode::Char('c'),
570            modifiers,
571        }) if modifiers.contains(KeyModifiers::CONTROL)
572    )
573}