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