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