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//! - **20+ built-in widgets** — input, textarea, table, list, tabs, button, checkbox, toggle, spinner, progress, toast, separator, help bar, scrollable, chart, bar chart, sparkline, histogram, canvas, grid
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//! | `serde` | Enable Serialize/Deserialize for Style, Color, Theme, and layout types |
37
38pub mod anim;
39pub mod buffer;
40pub mod cell;
41pub mod chart;
42pub mod context;
43pub mod event;
44pub mod layout;
45pub mod rect;
46pub mod style;
47mod terminal;
48pub mod test_utils;
49pub mod widgets;
50
51use std::io;
52use std::io::IsTerminal;
53use std::sync::Once;
54use std::time::{Duration, Instant};
55
56use terminal::{InlineTerminal, Terminal};
57
58pub use crate::test_utils::{EventBuilder, TestBackend};
59pub use anim::{Spring, Tween};
60pub use chart::{
61    Axis, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
62    HistogramBuilder, LegendPosition, Marker,
63};
64pub use context::{Bar, BarDirection, BarGroup, CanvasContext, Context, Response, Widget};
65pub use event::{Event, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseKind};
66pub use style::{Align, Border, Color, Constraints, Margin, Modifiers, Padding, Style, Theme};
67pub use widgets::{
68    ListState, ScrollState, SpinnerState, TableState, TabsState, TextInputState, TextareaState,
69    ToastLevel, ToastMessage, ToastState,
70};
71
72static PANIC_HOOK_ONCE: Once = Once::new();
73
74fn install_panic_hook() {
75    PANIC_HOOK_ONCE.call_once(|| {
76        let original = std::panic::take_hook();
77        std::panic::set_hook(Box::new(move |panic_info| {
78            let _ = crossterm::terminal::disable_raw_mode();
79            let mut stdout = io::stdout();
80            let _ = crossterm::execute!(
81                stdout,
82                crossterm::terminal::LeaveAlternateScreen,
83                crossterm::cursor::Show,
84                crossterm::event::DisableMouseCapture,
85                crossterm::event::DisableBracketedPaste,
86                crossterm::style::ResetColor,
87                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
88            );
89            original(panic_info);
90        }));
91    });
92}
93
94/// Configuration for a TUI run loop.
95///
96/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
97/// Use [`Default::default()`] for sensible defaults (100ms tick, no mouse, dark theme).
98///
99/// # Example
100///
101/// ```no_run
102/// use slt::{RunConfig, Theme};
103/// use std::time::Duration;
104///
105/// let config = RunConfig {
106///     tick_rate: Duration::from_millis(50),
107///     mouse: true,
108///     theme: Theme::light(),
109///     max_fps: Some(60),
110/// };
111/// ```
112#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
113pub struct RunConfig {
114    /// How long to wait for input before triggering a tick with no events.
115    ///
116    /// Lower values give smoother animations at the cost of more CPU usage.
117    /// Defaults to 100ms.
118    pub tick_rate: Duration,
119    /// Whether to enable mouse event reporting.
120    ///
121    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
122    /// Defaults to `false`.
123    pub mouse: bool,
124    /// The color theme applied to all widgets automatically.
125    ///
126    /// Defaults to [`Theme::dark()`].
127    pub theme: Theme,
128    /// Optional maximum frame rate.
129    ///
130    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
131    /// loop iteration to target that frame time.
132    pub max_fps: Option<u32>,
133}
134
135impl Default for RunConfig {
136    fn default() -> Self {
137        Self {
138            tick_rate: Duration::from_millis(100),
139            mouse: false,
140            theme: Theme::dark(),
141            max_fps: None,
142        }
143    }
144}
145
146/// Run the TUI loop with default configuration.
147///
148/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
149/// Ctrl+C or when [`Context::quit`] is called.
150///
151/// # Example
152///
153/// ```no_run
154/// fn main() -> std::io::Result<()> {
155///     slt::run(|ui| {
156///         ui.text("Press Ctrl+C to exit");
157///     })
158/// }
159/// ```
160pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
161    run_with(RunConfig::default(), f)
162}
163
164/// Run the TUI loop with custom configuration.
165///
166/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
167/// support, and theming.
168///
169/// # Example
170///
171/// ```no_run
172/// use slt::{RunConfig, Theme};
173///
174/// fn main() -> std::io::Result<()> {
175///     slt::run_with(
176///         RunConfig { theme: Theme::light(), ..Default::default() },
177///         |ui| {
178///             ui.text("Light theme!");
179///         },
180///     )
181/// }
182/// ```
183pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
184    if !io::stdout().is_terminal() {
185        return Ok(());
186    }
187
188    install_panic_hook();
189    let mut term = Terminal::new(config.mouse)?;
190    let mut events: Vec<Event> = Vec::new();
191    let mut debug_mode: bool = false;
192    let mut tick: u64 = 0;
193    let mut focus_index: usize = 0;
194    let mut prev_focus_count: usize = 0;
195    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
196    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
197    let mut last_mouse_pos: Option<(u32, u32)> = None;
198    let mut selection = terminal::SelectionState::default();
199
200    loop {
201        let frame_start = Instant::now();
202        let (w, h) = term.size();
203        if w == 0 || h == 0 {
204            sleep_for_fps_cap(config.max_fps, frame_start);
205            continue;
206        }
207        let mut ctx = Context::new(
208            std::mem::take(&mut events),
209            w,
210            h,
211            tick,
212            focus_index,
213            prev_focus_count,
214            std::mem::take(&mut prev_scroll_infos),
215            std::mem::take(&mut prev_hit_map),
216            debug_mode,
217            config.theme,
218            last_mouse_pos,
219        );
220        ctx.process_focus_keys();
221
222        f(&mut ctx);
223
224        if ctx.should_quit {
225            break;
226        }
227
228        let mut should_copy_selection = false;
229        for ev in ctx.events.iter() {
230            if let Event::Mouse(mouse) = ev {
231                match mouse.kind {
232                    event::MouseKind::Down(event::MouseButton::Left) => {
233                        selection.mouse_down(mouse.x, mouse.y, &ctx.prev_hit_map);
234                    }
235                    event::MouseKind::Drag(event::MouseButton::Left) => {
236                        selection.mouse_drag(mouse.x, mouse.y);
237                    }
238                    event::MouseKind::Up(event::MouseButton::Left) => {
239                        should_copy_selection = selection.active;
240                    }
241                    _ => {}
242                }
243            }
244        }
245
246        focus_index = ctx.focus_index;
247        prev_focus_count = ctx.focus_count;
248
249        let mut tree = layout::build_tree(&ctx.commands);
250        let area = crate::rect::Rect::new(0, 0, w, h);
251        layout::compute(&mut tree, area);
252        prev_scroll_infos = layout::collect_scroll_infos(&tree);
253        prev_hit_map = layout::collect_hit_areas(&tree);
254        layout::render(&tree, term.buffer_mut());
255        if debug_mode {
256            layout::render_debug_overlay(&tree, term.buffer_mut());
257        }
258
259        if selection.active {
260            terminal::apply_selection_overlay(term.buffer_mut(), &selection);
261        }
262        if should_copy_selection {
263            let text = terminal::extract_selection_text(term.buffer_mut(), &selection);
264            if !text.is_empty() {
265                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
266            }
267            selection.clear();
268        }
269
270        term.flush()?;
271        tick = tick.wrapping_add(1);
272
273        events.clear();
274        if crossterm::event::poll(config.tick_rate)? {
275            let raw = crossterm::event::read()?;
276            if let Some(ev) = event::from_crossterm(raw) {
277                if is_ctrl_c(&ev) {
278                    break;
279                }
280                if let Event::Resize(_, _) = &ev {
281                    term.handle_resize()?;
282                }
283                events.push(ev);
284            }
285
286            while crossterm::event::poll(Duration::ZERO)? {
287                let raw = crossterm::event::read()?;
288                if let Some(ev) = event::from_crossterm(raw) {
289                    if is_ctrl_c(&ev) {
290                        return Ok(());
291                    }
292                    if let Event::Resize(_, _) = &ev {
293                        term.handle_resize()?;
294                    }
295                    events.push(ev);
296                }
297            }
298
299            for ev in &events {
300                if matches!(
301                    ev,
302                    Event::Key(event::KeyEvent {
303                        code: KeyCode::F(12),
304                        ..
305                    })
306                ) {
307                    debug_mode = !debug_mode;
308                }
309            }
310        }
311
312        for ev in &events {
313            if let Event::Mouse(mouse) = ev {
314                last_mouse_pos = Some((mouse.x, mouse.y));
315            }
316        }
317
318        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
319            prev_hit_map.clear();
320            prev_scroll_infos.clear();
321            last_mouse_pos = None;
322        }
323
324        sleep_for_fps_cap(config.max_fps, frame_start);
325    }
326
327    Ok(())
328}
329
330/// Run the TUI loop asynchronously with default configuration.
331///
332/// Requires the `async` feature. Spawns the render loop in a blocking thread
333/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
334/// from async tasks into the UI closure.
335///
336/// # Example
337///
338/// ```no_run
339/// # #[cfg(feature = "async")]
340/// # async fn example() -> std::io::Result<()> {
341/// let tx = slt::run_async::<String>(|ui, messages| {
342///     for msg in messages.drain(..) {
343///         ui.text(msg);
344///     }
345/// })?;
346/// tx.send("hello from async".to_string()).await.ok();
347/// # Ok(())
348/// # }
349/// ```
350#[cfg(feature = "async")]
351pub fn run_async<M: Send + 'static>(
352    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
353) -> io::Result<tokio::sync::mpsc::Sender<M>> {
354    run_async_with(RunConfig::default(), f)
355}
356
357/// Run the TUI loop asynchronously with custom configuration.
358///
359/// Requires the `async` feature. Like [`run_async`], but accepts a
360/// [`RunConfig`] to control tick rate, mouse support, and theming.
361///
362/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
363#[cfg(feature = "async")]
364pub fn run_async_with<M: Send + 'static>(
365    config: RunConfig,
366    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
367) -> io::Result<tokio::sync::mpsc::Sender<M>> {
368    let (tx, rx) = tokio::sync::mpsc::channel(100);
369    let handle =
370        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
371
372    handle.spawn_blocking(move || {
373        let _ = run_async_loop(config, f, rx);
374    });
375
376    Ok(tx)
377}
378
379#[cfg(feature = "async")]
380fn run_async_loop<M: Send + 'static>(
381    config: RunConfig,
382    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
383    mut rx: tokio::sync::mpsc::Receiver<M>,
384) -> io::Result<()> {
385    if !io::stdout().is_terminal() {
386        return Ok(());
387    }
388
389    install_panic_hook();
390    let mut term = Terminal::new(config.mouse)?;
391    let mut events: Vec<Event> = Vec::new();
392    let mut tick: u64 = 0;
393    let mut focus_index: usize = 0;
394    let mut prev_focus_count: usize = 0;
395    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
396    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
397    let mut last_mouse_pos: Option<(u32, u32)> = None;
398    let mut selection = terminal::SelectionState::default();
399
400    loop {
401        let frame_start = Instant::now();
402        let mut messages: Vec<M> = Vec::new();
403        while let Ok(message) = rx.try_recv() {
404            messages.push(message);
405        }
406
407        let (w, h) = term.size();
408        if w == 0 || h == 0 {
409            sleep_for_fps_cap(config.max_fps, frame_start);
410            continue;
411        }
412        let mut ctx = Context::new(
413            std::mem::take(&mut events),
414            w,
415            h,
416            tick,
417            focus_index,
418            prev_focus_count,
419            std::mem::take(&mut prev_scroll_infos),
420            std::mem::take(&mut prev_hit_map),
421            false,
422            config.theme,
423            last_mouse_pos,
424        );
425        ctx.process_focus_keys();
426
427        f(&mut ctx, &mut messages);
428
429        if ctx.should_quit {
430            break;
431        }
432
433        let mut should_copy_selection = false;
434        for ev in ctx.events.iter() {
435            if let Event::Mouse(mouse) = ev {
436                match mouse.kind {
437                    event::MouseKind::Down(event::MouseButton::Left) => {
438                        selection.mouse_down(mouse.x, mouse.y, &ctx.prev_hit_map);
439                    }
440                    event::MouseKind::Drag(event::MouseButton::Left) => {
441                        selection.mouse_drag(mouse.x, mouse.y);
442                    }
443                    event::MouseKind::Up(event::MouseButton::Left) => {
444                        should_copy_selection = selection.active;
445                    }
446                    _ => {}
447                }
448            }
449        }
450
451        focus_index = ctx.focus_index;
452        prev_focus_count = ctx.focus_count;
453
454        let mut tree = layout::build_tree(&ctx.commands);
455        let area = crate::rect::Rect::new(0, 0, w, h);
456        layout::compute(&mut tree, area);
457        prev_scroll_infos = layout::collect_scroll_infos(&tree);
458        prev_hit_map = layout::collect_hit_areas(&tree);
459        layout::render(&tree, term.buffer_mut());
460
461        if selection.active {
462            terminal::apply_selection_overlay(term.buffer_mut(), &selection);
463        }
464        if should_copy_selection {
465            let text = terminal::extract_selection_text(term.buffer_mut(), &selection);
466            if !text.is_empty() {
467                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
468            }
469            selection.clear();
470        }
471
472        term.flush()?;
473        tick = tick.wrapping_add(1);
474
475        events.clear();
476        if crossterm::event::poll(config.tick_rate)? {
477            let raw = crossterm::event::read()?;
478            if let Some(ev) = event::from_crossterm(raw) {
479                if is_ctrl_c(&ev) {
480                    break;
481                }
482                if let Event::Resize(_, _) = &ev {
483                    term.handle_resize()?;
484                    prev_hit_map.clear();
485                    prev_scroll_infos.clear();
486                    last_mouse_pos = None;
487                }
488                events.push(ev);
489            }
490
491            while crossterm::event::poll(Duration::ZERO)? {
492                let raw = crossterm::event::read()?;
493                if let Some(ev) = event::from_crossterm(raw) {
494                    if is_ctrl_c(&ev) {
495                        return Ok(());
496                    }
497                    if let Event::Resize(_, _) = &ev {
498                        term.handle_resize()?;
499                        prev_hit_map.clear();
500                        prev_scroll_infos.clear();
501                        last_mouse_pos = None;
502                    }
503                    events.push(ev);
504                }
505            }
506        }
507
508        for ev in &events {
509            if let Event::Mouse(mouse) = ev {
510                last_mouse_pos = Some((mouse.x, mouse.y));
511            }
512        }
513
514        sleep_for_fps_cap(config.max_fps, frame_start);
515    }
516
517    Ok(())
518}
519
520/// Run the TUI in inline mode with default configuration.
521///
522/// Renders `height` rows directly below the current cursor position without
523/// entering alternate screen mode. Useful for CLI tools that want a small
524/// interactive widget below the prompt.
525///
526/// # Example
527///
528/// ```no_run
529/// fn main() -> std::io::Result<()> {
530///     slt::run_inline(3, |ui| {
531///         ui.text("Inline TUI — no alternate screen");
532///     })
533/// }
534/// ```
535pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
536    run_inline_with(height, RunConfig::default(), f)
537}
538
539/// Run the TUI in inline mode with custom configuration.
540///
541/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
542/// mouse support, and theming.
543pub fn run_inline_with(
544    height: u32,
545    config: RunConfig,
546    mut f: impl FnMut(&mut Context),
547) -> io::Result<()> {
548    if !io::stdout().is_terminal() {
549        return Ok(());
550    }
551
552    install_panic_hook();
553    let mut term = InlineTerminal::new(height, config.mouse)?;
554    let mut events: Vec<Event> = Vec::new();
555    let mut debug_mode: bool = false;
556    let mut tick: u64 = 0;
557    let mut focus_index: usize = 0;
558    let mut prev_focus_count: usize = 0;
559    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
560    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
561    let mut last_mouse_pos: Option<(u32, u32)> = None;
562    let mut selection = terminal::SelectionState::default();
563
564    loop {
565        let frame_start = Instant::now();
566        let (w, h) = term.size();
567        if w == 0 || h == 0 {
568            sleep_for_fps_cap(config.max_fps, frame_start);
569            continue;
570        }
571        let mut ctx = Context::new(
572            std::mem::take(&mut events),
573            w,
574            h,
575            tick,
576            focus_index,
577            prev_focus_count,
578            std::mem::take(&mut prev_scroll_infos),
579            std::mem::take(&mut prev_hit_map),
580            debug_mode,
581            config.theme,
582            last_mouse_pos,
583        );
584        ctx.process_focus_keys();
585
586        f(&mut ctx);
587
588        if ctx.should_quit {
589            break;
590        }
591
592        let mut should_copy_selection = false;
593        for ev in ctx.events.iter() {
594            if let Event::Mouse(mouse) = ev {
595                match mouse.kind {
596                    event::MouseKind::Down(event::MouseButton::Left) => {
597                        selection.mouse_down(mouse.x, mouse.y, &ctx.prev_hit_map);
598                    }
599                    event::MouseKind::Drag(event::MouseButton::Left) => {
600                        selection.mouse_drag(mouse.x, mouse.y);
601                    }
602                    event::MouseKind::Up(event::MouseButton::Left) => {
603                        should_copy_selection = selection.active;
604                    }
605                    _ => {}
606                }
607            }
608        }
609
610        focus_index = ctx.focus_index;
611        prev_focus_count = ctx.focus_count;
612
613        let mut tree = layout::build_tree(&ctx.commands);
614        let area = crate::rect::Rect::new(0, 0, w, h);
615        layout::compute(&mut tree, area);
616        prev_scroll_infos = layout::collect_scroll_infos(&tree);
617        prev_hit_map = layout::collect_hit_areas(&tree);
618        layout::render(&tree, term.buffer_mut());
619        if debug_mode {
620            layout::render_debug_overlay(&tree, term.buffer_mut());
621        }
622
623        if selection.active {
624            terminal::apply_selection_overlay(term.buffer_mut(), &selection);
625        }
626        if should_copy_selection {
627            let text = terminal::extract_selection_text(term.buffer_mut(), &selection);
628            if !text.is_empty() {
629                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
630            }
631            selection.clear();
632        }
633
634        term.flush()?;
635        tick = tick.wrapping_add(1);
636
637        events.clear();
638        if crossterm::event::poll(config.tick_rate)? {
639            let raw = crossterm::event::read()?;
640            if let Some(ev) = event::from_crossterm(raw) {
641                if is_ctrl_c(&ev) {
642                    break;
643                }
644                if let Event::Resize(_, _) = &ev {
645                    term.handle_resize()?;
646                }
647                events.push(ev);
648            }
649
650            while crossterm::event::poll(Duration::ZERO)? {
651                let raw = crossterm::event::read()?;
652                if let Some(ev) = event::from_crossterm(raw) {
653                    if is_ctrl_c(&ev) {
654                        return Ok(());
655                    }
656                    if let Event::Resize(_, _) = &ev {
657                        term.handle_resize()?;
658                    }
659                    events.push(ev);
660                }
661            }
662
663            for ev in &events {
664                if matches!(
665                    ev,
666                    Event::Key(event::KeyEvent {
667                        code: KeyCode::F(12),
668                        ..
669                    })
670                ) {
671                    debug_mode = !debug_mode;
672                }
673            }
674        }
675
676        for ev in &events {
677            if let Event::Mouse(mouse) = ev {
678                last_mouse_pos = Some((mouse.x, mouse.y));
679            }
680        }
681
682        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
683            prev_hit_map.clear();
684            prev_scroll_infos.clear();
685            last_mouse_pos = None;
686        }
687
688        sleep_for_fps_cap(config.max_fps, frame_start);
689    }
690
691    Ok(())
692}
693
694fn is_ctrl_c(ev: &Event) -> bool {
695    matches!(
696        ev,
697        Event::Key(event::KeyEvent {
698            code: KeyCode::Char('c'),
699            modifiers,
700        }) if modifiers.contains(KeyModifiers::CONTROL)
701    )
702}
703
704fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
705    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
706        let target = Duration::from_secs_f64(1.0 / fps as f64);
707        let elapsed = frame_start.elapsed();
708        if elapsed < target {
709            std::thread::sleep(target - elapsed);
710        }
711    }
712}