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