Skip to main content

slt/
lib.rs

1// Safety
2#![forbid(unsafe_code)]
3// Documentation
4#![cfg_attr(docsrs, feature(doc_cfg))]
5#![warn(rustdoc::broken_intra_doc_links)]
6#![warn(missing_docs)]
7#![warn(rustdoc::private_intra_doc_links)]
8// Correctness
9#![deny(clippy::unwrap_in_result)]
10#![warn(clippy::unwrap_used)]
11// Library hygiene — a library must not write to stdout/stderr
12#![warn(clippy::dbg_macro)]
13#![warn(clippy::print_stdout)]
14#![warn(clippy::print_stderr)]
15
16//! # SLT — Super Light TUI
17//!
18//! Immediate-mode terminal UI for Rust. Two dependencies. Zero `unsafe`.
19//!
20//! SLT gives you an egui-style API for terminals: your closure runs each frame,
21//! you describe your UI, and SLT handles layout, diffing, and rendering.
22//!
23//! ## Quick Start
24//!
25//! ```no_run
26//! fn main() -> std::io::Result<()> {
27//!     slt::run(|ui| {
28//!         ui.text("hello, world");
29//!     })
30//! }
31//! ```
32//!
33//! ## Features
34//!
35//! - **Flexbox layout** — `row()`, `col()`, `gap()`, `grow()`
36//! - **30+ 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, select, radio, multi-select, tree, virtual list, command palette, markdown
37//! - **Styling** — bold, italic, dim, underline, 256 colors, RGB
38//! - **Mouse** — click, hover, drag-to-scroll
39//! - **Focus** — automatic Tab/Shift+Tab cycling
40//! - **Theming** — dark/light presets or custom
41//! - **Animation** — tween and spring primitives with 9 easing functions
42//! - **Inline mode** — render below your prompt, no alternate screen
43//! - **Async** — optional tokio integration via `async` feature
44//! - **Layout debugger** — F12 to visualize container bounds
45//!
46//! ## Feature Flags
47//!
48//! | Flag | Description |
49//! |------|-------------|
50//! | `async` | Enable `run_async()` with tokio channel-based message passing |
51//! | `serde` | Enable Serialize/Deserialize for Style, Color, Theme, and layout types |
52
53pub mod anim;
54pub mod buffer;
55/// Terminal cell representation.
56pub mod cell;
57/// Chart and data visualization widgets.
58pub mod chart;
59/// UI context, container builder, and widget rendering.
60pub mod context;
61/// Input events (keyboard, mouse, resize, paste).
62pub mod event;
63/// Half-block image rendering.
64pub mod halfblock;
65/// Keyboard shortcut mapping.
66pub mod keymap;
67/// Flexbox layout engine and command tree.
68pub mod layout;
69/// Color palettes (Tailwind-style).
70pub mod palette;
71pub mod rect;
72#[cfg(feature = "crossterm")]
73mod sixel;
74pub mod style;
75pub mod syntax;
76#[cfg(feature = "crossterm")]
77mod terminal;
78pub mod test_utils;
79pub mod widgets;
80
81use std::io;
82#[cfg(feature = "crossterm")]
83use std::io::IsTerminal;
84#[cfg(feature = "crossterm")]
85use std::io::Write;
86#[cfg(feature = "crossterm")]
87use std::sync::Once;
88use std::time::{Duration, Instant};
89
90#[cfg(feature = "crossterm")]
91pub use terminal::{detect_color_scheme, read_clipboard, ColorScheme};
92#[cfg(feature = "crossterm")]
93use terminal::{InlineTerminal, Terminal};
94
95pub use crate::test_utils::{EventBuilder, TestBackend};
96pub use anim::{
97    ease_in_cubic, ease_in_out_cubic, ease_in_out_quad, ease_in_quad, ease_linear, ease_out_bounce,
98    ease_out_cubic, ease_out_elastic, ease_out_quad, lerp, Keyframes, LoopMode, Sequence, Spring,
99    Stagger, Tween,
100};
101pub use buffer::Buffer;
102pub use cell::Cell;
103pub use chart::{
104    Axis, Candle, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
105    HistogramBuilder, LegendPosition, Marker,
106};
107pub use context::{
108    Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context,
109    Response, State, Widget,
110};
111pub use event::{
112    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
113};
114pub use halfblock::HalfBlockImage;
115pub use keymap::{Binding, KeyMap};
116pub use layout::Direction;
117pub use palette::Palette;
118pub use rect::Rect;
119pub use style::{
120    Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
121    Justify, Margin, Modifiers, Padding, Style, Theme, ThemeBuilder, WidgetColors,
122};
123pub use widgets::{
124    AlertLevel, ApprovalAction, ButtonVariant, CalendarState, CommandPaletteState, ContextItem,
125    DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, ListState,
126    MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState, ScreenState,
127    ScrollState, SelectState, SpinnerState, StaticOutput, StreamingMarkdownState,
128    StreamingTextState, TableState, TabsState, TextInputState, TextareaState, ToastLevel,
129    ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
130};
131
132/// Rendering backend for SLT.
133///
134/// Implement this trait to render SLT UIs to custom targets — alternative
135/// terminals, GUI embeds, test harnesses, WASM canvas, etc.
136///
137/// The built-in terminal backend ([`run()`], [`run_with()`]) handles setup,
138/// teardown, and event polling automatically. For custom backends, pair this
139/// trait with [`AppState`] and [`frame()`] to drive the render loop yourself.
140///
141/// # Example
142///
143/// ```ignore
144/// use slt::{Backend, AppState, Buffer, Rect, RunConfig, Context, Event};
145///
146/// struct MyBackend {
147///     buffer: Buffer,
148/// }
149///
150/// impl Backend for MyBackend {
151///     fn size(&self) -> (u32, u32) {
152///         (self.buffer.area.width, self.buffer.area.height)
153///     }
154///     fn buffer_mut(&mut self) -> &mut Buffer {
155///         &mut self.buffer
156///     }
157///     fn flush(&mut self) -> std::io::Result<()> {
158///         // Render self.buffer to your target
159///         Ok(())
160///     }
161/// }
162///
163/// fn main() -> std::io::Result<()> {
164///     let mut backend = MyBackend {
165///         buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
166///     };
167///     let mut state = AppState::new();
168///     let config = RunConfig::default();
169///
170///     loop {
171///         let events: Vec<Event> = vec![]; // Collect your own events
172///         if !slt::frame(&mut backend, &mut state, &config, &events, &mut |ui| {
173///             ui.text("Hello from custom backend!");
174///         })? {
175///             break;
176///         }
177///     }
178///     Ok(())
179/// }
180/// ```
181pub trait Backend {
182    /// Returns the current display size as `(width, height)` in cells.
183    fn size(&self) -> (u32, u32);
184
185    /// Returns a mutable reference to the display buffer.
186    ///
187    /// SLT writes the UI into this buffer each frame. After [`frame()`]
188    /// returns, call [`flush()`](Backend::flush) to present the result.
189    fn buffer_mut(&mut self) -> &mut Buffer;
190
191    /// Flush the buffer contents to the display.
192    ///
193    /// Called automatically at the end of each [`frame()`] call. Implementations
194    /// should present the current buffer to the user — by writing ANSI escapes,
195    /// drawing to a canvas, updating a texture, etc.
196    fn flush(&mut self) -> io::Result<()>;
197}
198
199/// Opaque per-session state that persists between frames.
200///
201/// Tracks focus, scroll positions, hook state, and other frame-to-frame data.
202/// Create with [`AppState::new()`] and pass to [`frame()`] each iteration.
203///
204/// # Example
205///
206/// ```ignore
207/// let mut state = slt::AppState::new();
208/// // state is passed to slt::frame() in your render loop
209/// ```
210pub struct AppState {
211    pub(crate) inner: FrameState,
212}
213
214impl AppState {
215    /// Create a new empty application state.
216    pub fn new() -> Self {
217        Self {
218            inner: FrameState::default(),
219        }
220    }
221
222    /// Returns the current frame tick count (increments each frame).
223    pub fn tick(&self) -> u64 {
224        self.inner.tick
225    }
226
227    /// Returns the smoothed FPS estimate (exponential moving average).
228    pub fn fps(&self) -> f32 {
229        self.inner.fps_ema
230    }
231
232    /// Toggle the debug overlay (same as pressing F12).
233    pub fn set_debug(&mut self, enabled: bool) {
234        self.inner.debug_mode = enabled;
235    }
236}
237
238impl Default for AppState {
239    fn default() -> Self {
240        Self::new()
241    }
242}
243
244/// Process a single UI frame with a custom [`Backend`].
245///
246/// This is the low-level entry point for custom backends. For standard terminal
247/// usage, prefer [`run()`] or [`run_with()`] which handle the event loop,
248/// terminal setup, and teardown automatically.
249///
250/// Returns `Ok(true)` to continue, `Ok(false)` when [`Context::quit()`] was
251/// called.
252///
253/// # Arguments
254///
255/// * `backend` — Your [`Backend`] implementation
256/// * `state` — Persistent [`AppState`] (reuse across frames)
257/// * `config` — [`RunConfig`] (theme, tick rate, etc.)
258/// * `events` — Input events for this frame (keyboard, mouse, resize)
259/// * `f` — Your UI closure, called once per frame
260///
261/// # Example
262///
263/// ```ignore
264/// let keep_going = slt::frame(
265///     &mut my_backend,
266///     &mut state,
267///     &config,
268///     &events,
269///     &mut |ui| { ui.text("hello"); },
270/// )?;
271/// ```
272pub fn frame(
273    backend: &mut impl Backend,
274    state: &mut AppState,
275    config: &RunConfig,
276    events: &[Event],
277    f: &mut impl FnMut(&mut Context),
278) -> io::Result<bool> {
279    run_frame(backend, &mut state.inner, config, events, f)
280}
281
282#[cfg(feature = "crossterm")]
283static PANIC_HOOK_ONCE: Once = Once::new();
284
285#[allow(clippy::print_stderr)]
286#[cfg(feature = "crossterm")]
287fn install_panic_hook() {
288    PANIC_HOOK_ONCE.call_once(|| {
289        let original = std::panic::take_hook();
290        std::panic::set_hook(Box::new(move |panic_info| {
291            let _ = crossterm::terminal::disable_raw_mode();
292            let mut stdout = io::stdout();
293            let _ = crossterm::execute!(
294                stdout,
295                crossterm::terminal::LeaveAlternateScreen,
296                crossterm::cursor::Show,
297                crossterm::event::DisableMouseCapture,
298                crossterm::event::DisableBracketedPaste,
299                crossterm::style::ResetColor,
300                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
301            );
302
303            // Print friendly panic header
304            eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
305
306            // Print location if available
307            if let Some(location) = panic_info.location() {
308                eprintln!(
309                    "\x1b[90m{}:{}:{}\x1b[0m",
310                    location.file(),
311                    location.line(),
312                    location.column()
313                );
314            }
315
316            // Print message
317            if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
318                eprintln!("\x1b[1m{}\x1b[0m", msg);
319            } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
320                eprintln!("\x1b[1m{}\x1b[0m", msg);
321            }
322
323            eprintln!(
324                "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
325            );
326
327            original(panic_info);
328        }));
329    });
330}
331
332/// Configuration for a TUI run loop.
333///
334/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
335/// Use [`Default::default()`] for sensible defaults (16ms tick / 60fps, no mouse, dark theme).
336///
337/// # Example
338///
339/// ```no_run
340/// use slt::{RunConfig, Theme};
341/// use std::time::Duration;
342///
343/// let config = RunConfig::default()
344///     .tick_rate(Duration::from_millis(50))
345///     .mouse(true)
346///     .theme(Theme::light())
347///     .max_fps(60);
348/// ```
349#[non_exhaustive]
350#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
351pub struct RunConfig {
352    /// How long to wait for input before triggering a tick with no events.
353    ///
354    /// Lower values give smoother animations at the cost of more CPU usage.
355    /// Defaults to 16ms (60fps).
356    pub tick_rate: Duration,
357    /// Whether to enable mouse event reporting.
358    ///
359    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
360    /// Defaults to `false`.
361    pub mouse: bool,
362    /// Whether to enable the Kitty keyboard protocol for enhanced input.
363    ///
364    /// When `true`, enables disambiguated key events, key release events,
365    /// and modifier-only key reporting on supporting terminals (kitty, Ghostty, WezTerm).
366    /// Terminals that don't support it silently ignore the request.
367    /// Defaults to `false`.
368    pub kitty_keyboard: bool,
369    /// The color theme applied to all widgets automatically.
370    ///
371    /// Defaults to [`Theme::dark()`].
372    pub theme: Theme,
373    /// Color depth override.
374    ///
375    /// `None` means auto-detect from `$COLORTERM` and `$TERM` environment
376    /// variables. Set explicitly to force a specific color depth regardless
377    /// of terminal capabilities.
378    pub color_depth: Option<ColorDepth>,
379    /// Optional maximum frame rate.
380    ///
381    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
382    /// loop iteration to target that frame time.
383    pub max_fps: Option<u32>,
384    /// Lines scrolled per mouse scroll event. Defaults to 1.
385    pub scroll_speed: u32,
386    /// Optional terminal window title (set via OSC 2).
387    pub title: Option<String>,
388}
389
390impl Default for RunConfig {
391    fn default() -> Self {
392        Self {
393            tick_rate: Duration::from_millis(16),
394            mouse: false,
395            kitty_keyboard: false,
396            theme: Theme::dark(),
397            color_depth: None,
398            max_fps: Some(60),
399            scroll_speed: 1,
400            title: None,
401        }
402    }
403}
404
405impl RunConfig {
406    /// Set the tick rate (input polling interval).
407    pub fn tick_rate(mut self, rate: Duration) -> Self {
408        self.tick_rate = rate;
409        self
410    }
411
412    /// Enable or disable mouse event reporting.
413    pub fn mouse(mut self, enabled: bool) -> Self {
414        self.mouse = enabled;
415        self
416    }
417
418    /// Enable or disable Kitty keyboard protocol.
419    pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
420        self.kitty_keyboard = enabled;
421        self
422    }
423
424    /// Set the color theme.
425    pub fn theme(mut self, theme: Theme) -> Self {
426        self.theme = theme;
427        self
428    }
429
430    /// Override the color depth.
431    pub fn color_depth(mut self, depth: ColorDepth) -> Self {
432        self.color_depth = Some(depth);
433        self
434    }
435
436    /// Set the maximum frame rate.
437    pub fn max_fps(mut self, fps: u32) -> Self {
438        self.max_fps = Some(fps);
439        self
440    }
441
442    /// Set the scroll speed (lines per scroll event).
443    pub fn scroll_speed(mut self, lines: u32) -> Self {
444        self.scroll_speed = lines.max(1);
445        self
446    }
447
448    /// Set the terminal window title.
449    pub fn title(mut self, title: impl Into<String>) -> Self {
450        self.title = Some(title.into());
451        self
452    }
453}
454
455pub(crate) struct FrameState {
456    pub hook_states: Vec<Box<dyn std::any::Any>>,
457    pub focus_index: usize,
458    pub prev_focus_count: usize,
459    pub prev_modal_focus_start: usize,
460    pub prev_modal_focus_count: usize,
461    pub tick: u64,
462    pub prev_scroll_infos: Vec<(u32, u32)>,
463    pub prev_scroll_rects: Vec<rect::Rect>,
464    pub prev_hit_map: Vec<rect::Rect>,
465    pub prev_group_rects: Vec<(String, rect::Rect)>,
466    pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
467    pub prev_focus_rects: Vec<(usize, rect::Rect)>,
468    pub prev_focus_groups: Vec<Option<String>>,
469    pub last_mouse_pos: Option<(u32, u32)>,
470    pub prev_modal_active: bool,
471    pub notification_queue: Vec<(String, ToastLevel, u64)>,
472    pub debug_mode: bool,
473    pub fps_ema: f32,
474    #[cfg(feature = "crossterm")]
475    pub selection: terminal::SelectionState,
476}
477
478impl Default for FrameState {
479    fn default() -> Self {
480        Self {
481            hook_states: Vec::new(),
482            focus_index: 0,
483            prev_focus_count: 0,
484            prev_modal_focus_start: 0,
485            prev_modal_focus_count: 0,
486            tick: 0,
487            prev_scroll_infos: Vec::new(),
488            prev_scroll_rects: Vec::new(),
489            prev_hit_map: Vec::new(),
490            prev_group_rects: Vec::new(),
491            prev_content_map: Vec::new(),
492            prev_focus_rects: Vec::new(),
493            prev_focus_groups: Vec::new(),
494            last_mouse_pos: None,
495            prev_modal_active: false,
496            notification_queue: Vec::new(),
497            debug_mode: false,
498            fps_ema: 0.0,
499            #[cfg(feature = "crossterm")]
500            selection: terminal::SelectionState::default(),
501        }
502    }
503}
504
505/// Run the TUI loop with default configuration.
506///
507/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
508/// Ctrl+C or when [`Context::quit`] is called.
509///
510/// # Example
511///
512/// ```no_run
513/// fn main() -> std::io::Result<()> {
514///     slt::run(|ui| {
515///         ui.text("Press Ctrl+C to exit");
516///     })
517/// }
518/// ```
519#[cfg(feature = "crossterm")]
520pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
521    run_with(RunConfig::default(), f)
522}
523
524#[cfg(feature = "crossterm")]
525fn set_terminal_title(title: &Option<String>) {
526    if let Some(title) = title {
527        use std::io::Write;
528        let _ = write!(io::stdout(), "\x1b]2;{title}\x07");
529    }
530}
531
532/// Run the TUI loop with custom configuration.
533///
534/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
535/// support, and theming.
536///
537/// # Example
538///
539/// ```no_run
540/// use slt::{RunConfig, Theme};
541///
542/// fn main() -> std::io::Result<()> {
543///     slt::run_with(
544///         RunConfig::default().theme(Theme::light()),
545///         |ui| {
546///             ui.text("Light theme!");
547///         },
548///     )
549/// }
550/// ```
551#[cfg(feature = "crossterm")]
552pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
553    if !io::stdout().is_terminal() {
554        return Ok(());
555    }
556
557    install_panic_hook();
558    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
559    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
560    set_terminal_title(&config.title);
561    if config.theme.bg != Color::Reset {
562        term.theme_bg = Some(config.theme.bg);
563    }
564    let mut events: Vec<Event> = Vec::new();
565    let mut state = FrameState::default();
566
567    loop {
568        let frame_start = Instant::now();
569        let (w, h) = term.size();
570        if w == 0 || h == 0 {
571            sleep_for_fps_cap(config.max_fps, frame_start);
572            continue;
573        }
574
575        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
576            break;
577        }
578
579        events.clear();
580        if crossterm::event::poll(config.tick_rate)? {
581            let raw = crossterm::event::read()?;
582            if let Some(ev) = event::from_crossterm(raw) {
583                if is_ctrl_c(&ev) {
584                    break;
585                }
586                if let Event::Resize(_, _) = &ev {
587                    term.handle_resize()?;
588                }
589                events.push(ev);
590            }
591
592            while crossterm::event::poll(Duration::ZERO)? {
593                let raw = crossterm::event::read()?;
594                if let Some(ev) = event::from_crossterm(raw) {
595                    if is_ctrl_c(&ev) {
596                        return Ok(());
597                    }
598                    if let Event::Resize(_, _) = &ev {
599                        term.handle_resize()?;
600                    }
601                    events.push(ev);
602                }
603            }
604
605            for ev in &events {
606                if matches!(
607                    ev,
608                    Event::Key(event::KeyEvent {
609                        code: KeyCode::F(12),
610                        kind: event::KeyEventKind::Press,
611                        ..
612                    })
613                ) {
614                    state.debug_mode = !state.debug_mode;
615                }
616            }
617        }
618
619        update_last_mouse_pos(&mut state, &events);
620
621        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
622            clear_frame_layout_cache(&mut state);
623        }
624
625        sleep_for_fps_cap(config.max_fps, frame_start);
626    }
627
628    Ok(())
629}
630
631/// Run the TUI loop asynchronously with default configuration.
632///
633/// Requires the `async` feature. Spawns the render loop in a blocking thread
634/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
635/// from async tasks into the UI closure.
636///
637/// # Example
638///
639/// ```no_run
640/// # #[cfg(feature = "async")]
641/// # async fn example() -> std::io::Result<()> {
642/// let tx = slt::run_async::<String>(|ui, messages| {
643///     for msg in messages.drain(..) {
644///         ui.text(msg);
645///     }
646/// })?;
647/// tx.send("hello from async".to_string()).await.ok();
648/// # Ok(())
649/// # }
650/// ```
651#[cfg(all(feature = "crossterm", feature = "async"))]
652pub fn run_async<M: Send + 'static>(
653    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
654) -> io::Result<tokio::sync::mpsc::Sender<M>> {
655    run_async_with(RunConfig::default(), f)
656}
657
658/// Run the TUI loop asynchronously with custom configuration.
659///
660/// Requires the `async` feature. Like [`run_async`], but accepts a
661/// [`RunConfig`] to control tick rate, mouse support, and theming.
662///
663/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
664#[cfg(all(feature = "crossterm", feature = "async"))]
665pub fn run_async_with<M: Send + 'static>(
666    config: RunConfig,
667    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
668) -> io::Result<tokio::sync::mpsc::Sender<M>> {
669    let (tx, rx) = tokio::sync::mpsc::channel(100);
670    let handle =
671        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
672
673    handle.spawn_blocking(move || {
674        let _ = run_async_loop(config, f, rx);
675    });
676
677    Ok(tx)
678}
679
680#[cfg(all(feature = "crossterm", feature = "async"))]
681fn run_async_loop<M: Send + 'static>(
682    config: RunConfig,
683    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
684    mut rx: tokio::sync::mpsc::Receiver<M>,
685) -> io::Result<()> {
686    if !io::stdout().is_terminal() {
687        return Ok(());
688    }
689
690    install_panic_hook();
691    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
692    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
693    set_terminal_title(&config.title);
694    if config.theme.bg != Color::Reset {
695        term.theme_bg = Some(config.theme.bg);
696    }
697    let mut events: Vec<Event> = Vec::new();
698    let mut state = FrameState::default();
699
700    loop {
701        let frame_start = Instant::now();
702        let mut messages: Vec<M> = Vec::new();
703        while let Ok(message) = rx.try_recv() {
704            messages.push(message);
705        }
706
707        let (w, h) = term.size();
708        if w == 0 || h == 0 {
709            sleep_for_fps_cap(config.max_fps, frame_start);
710            continue;
711        }
712
713        let mut render = |ctx: &mut Context| {
714            f(ctx, &mut messages);
715        };
716        if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
717            break;
718        }
719
720        events.clear();
721        if crossterm::event::poll(config.tick_rate)? {
722            let raw = crossterm::event::read()?;
723            if let Some(ev) = event::from_crossterm(raw) {
724                if is_ctrl_c(&ev) {
725                    break;
726                }
727                if let Event::Resize(_, _) = &ev {
728                    term.handle_resize()?;
729                    clear_frame_layout_cache(&mut state);
730                }
731                events.push(ev);
732            }
733
734            while crossterm::event::poll(Duration::ZERO)? {
735                let raw = crossterm::event::read()?;
736                if let Some(ev) = event::from_crossterm(raw) {
737                    if is_ctrl_c(&ev) {
738                        return Ok(());
739                    }
740                    if let Event::Resize(_, _) = &ev {
741                        term.handle_resize()?;
742                        clear_frame_layout_cache(&mut state);
743                    }
744                    events.push(ev);
745                }
746            }
747        }
748
749        update_last_mouse_pos(&mut state, &events);
750
751        sleep_for_fps_cap(config.max_fps, frame_start);
752    }
753
754    Ok(())
755}
756
757/// Run the TUI in inline mode with default configuration.
758///
759/// Renders `height` rows directly below the current cursor position without
760/// entering alternate screen mode. Useful for CLI tools that want a small
761/// interactive widget below the prompt.
762///
763/// # Example
764///
765/// ```no_run
766/// fn main() -> std::io::Result<()> {
767///     slt::run_inline(3, |ui| {
768///         ui.text("Inline TUI — no alternate screen");
769///     })
770/// }
771/// ```
772#[cfg(feature = "crossterm")]
773pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
774    run_inline_with(height, RunConfig::default(), f)
775}
776
777/// Run the TUI in inline mode with custom configuration.
778///
779/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
780/// mouse support, and theming.
781#[cfg(feature = "crossterm")]
782pub fn run_inline_with(
783    height: u32,
784    config: RunConfig,
785    mut f: impl FnMut(&mut Context),
786) -> io::Result<()> {
787    if !io::stdout().is_terminal() {
788        return Ok(());
789    }
790
791    install_panic_hook();
792    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
793    let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
794    set_terminal_title(&config.title);
795    if config.theme.bg != Color::Reset {
796        term.theme_bg = Some(config.theme.bg);
797    }
798    let mut events: Vec<Event> = Vec::new();
799    let mut state = FrameState::default();
800
801    loop {
802        let frame_start = Instant::now();
803        let (w, h) = term.size();
804        if w == 0 || h == 0 {
805            sleep_for_fps_cap(config.max_fps, frame_start);
806            continue;
807        }
808
809        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
810            break;
811        }
812
813        events.clear();
814        if crossterm::event::poll(config.tick_rate)? {
815            let raw = crossterm::event::read()?;
816            if let Some(ev) = event::from_crossterm(raw) {
817                if is_ctrl_c(&ev) {
818                    break;
819                }
820                if let Event::Resize(_, _) = &ev {
821                    term.handle_resize()?;
822                }
823                events.push(ev);
824            }
825
826            while crossterm::event::poll(Duration::ZERO)? {
827                let raw = crossterm::event::read()?;
828                if let Some(ev) = event::from_crossterm(raw) {
829                    if is_ctrl_c(&ev) {
830                        return Ok(());
831                    }
832                    if let Event::Resize(_, _) = &ev {
833                        term.handle_resize()?;
834                    }
835                    events.push(ev);
836                }
837            }
838
839            for ev in &events {
840                if matches!(
841                    ev,
842                    Event::Key(event::KeyEvent {
843                        code: KeyCode::F(12),
844                        kind: event::KeyEventKind::Press,
845                        ..
846                    })
847                ) {
848                    state.debug_mode = !state.debug_mode;
849                }
850            }
851        }
852
853        update_last_mouse_pos(&mut state, &events);
854
855        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
856            clear_frame_layout_cache(&mut state);
857        }
858
859        sleep_for_fps_cap(config.max_fps, frame_start);
860    }
861
862    Ok(())
863}
864
865/// Run the TUI in static-output mode.
866///
867/// Static lines written through [`StaticOutput`] are printed into terminal
868/// scrollback, while the interactive UI stays rendered in a fixed-height inline
869/// area at the bottom.
870#[cfg(feature = "crossterm")]
871pub fn run_static(
872    output: &mut StaticOutput,
873    dynamic_height: u32,
874    mut f: impl FnMut(&mut Context),
875) -> io::Result<()> {
876    let config = RunConfig::default();
877    if !io::stdout().is_terminal() {
878        return Ok(());
879    }
880
881    install_panic_hook();
882
883    let initial_lines = output.drain_new();
884    write_static_lines(&initial_lines)?;
885
886    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
887    let mut term = InlineTerminal::new(dynamic_height, config.mouse, color_depth)?;
888    set_terminal_title(&config.title);
889    if config.theme.bg != Color::Reset {
890        term.theme_bg = Some(config.theme.bg);
891    }
892
893    let mut events: Vec<Event> = Vec::new();
894    let mut state = FrameState::default();
895
896    loop {
897        let frame_start = Instant::now();
898        let (w, h) = term.size();
899        if w == 0 || h == 0 {
900            sleep_for_fps_cap(config.max_fps, frame_start);
901            continue;
902        }
903
904        let new_lines = output.drain_new();
905        write_static_lines(&new_lines)?;
906
907        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
908            break;
909        }
910
911        events.clear();
912        if crossterm::event::poll(config.tick_rate)? {
913            let raw = crossterm::event::read()?;
914            if let Some(ev) = event::from_crossterm(raw) {
915                if is_ctrl_c(&ev) {
916                    break;
917                }
918                if let Event::Resize(_, _) = &ev {
919                    term.handle_resize()?;
920                }
921                events.push(ev);
922            }
923
924            while crossterm::event::poll(Duration::ZERO)? {
925                let raw = crossterm::event::read()?;
926                if let Some(ev) = event::from_crossterm(raw) {
927                    if is_ctrl_c(&ev) {
928                        return Ok(());
929                    }
930                    if let Event::Resize(_, _) = &ev {
931                        term.handle_resize()?;
932                    }
933                    events.push(ev);
934                }
935            }
936
937            for ev in &events {
938                if matches!(
939                    ev,
940                    Event::Key(event::KeyEvent {
941                        code: KeyCode::F(12),
942                        kind: event::KeyEventKind::Press,
943                        ..
944                    })
945                ) {
946                    state.debug_mode = !state.debug_mode;
947                }
948            }
949        }
950
951        update_last_mouse_pos(&mut state, &events);
952
953        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
954            clear_frame_layout_cache(&mut state);
955        }
956
957        sleep_for_fps_cap(config.max_fps, frame_start);
958    }
959
960    Ok(())
961}
962
963#[cfg(feature = "crossterm")]
964fn write_static_lines(lines: &[String]) -> io::Result<()> {
965    if lines.is_empty() {
966        return Ok(());
967    }
968
969    let mut stdout = io::stdout();
970    for line in lines {
971        stdout.write_all(line.as_bytes())?;
972        stdout.write_all(b"\r\n")?;
973    }
974    stdout.flush()
975}
976
977fn run_frame(
978    term: &mut impl Backend,
979    state: &mut FrameState,
980    config: &RunConfig,
981    events: &[event::Event],
982    f: &mut impl FnMut(&mut context::Context),
983) -> io::Result<bool> {
984    let frame_start = Instant::now();
985    let (w, h) = term.size();
986    let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
987    ctx.is_real_terminal = true;
988    ctx.set_scroll_speed(config.scroll_speed);
989
990    f(&mut ctx);
991    ctx.process_focus_keys();
992    ctx.render_notifications();
993    ctx.emit_pending_tooltips();
994
995    if ctx.should_quit {
996        return Ok(false);
997    }
998    state.prev_modal_active = ctx.modal_active;
999    state.prev_modal_focus_start = ctx.modal_focus_start;
1000    state.prev_modal_focus_count = ctx.modal_focus_count;
1001    #[cfg(feature = "crossterm")]
1002    let clipboard_text = ctx.clipboard_text.take();
1003    #[cfg(not(feature = "crossterm"))]
1004    let _clipboard_text = ctx.clipboard_text.take();
1005
1006    #[cfg(feature = "crossterm")]
1007    let mut should_copy_selection = false;
1008    #[cfg(feature = "crossterm")]
1009    for ev in &ctx.events {
1010        if let Event::Mouse(mouse) = ev {
1011            match mouse.kind {
1012                event::MouseKind::Down(event::MouseButton::Left) => {
1013                    state
1014                        .selection
1015                        .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
1016                }
1017                event::MouseKind::Drag(event::MouseButton::Left) => {
1018                    state
1019                        .selection
1020                        .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
1021                }
1022                event::MouseKind::Up(event::MouseButton::Left) => {
1023                    should_copy_selection = state.selection.active;
1024                }
1025                _ => {}
1026            }
1027        }
1028    }
1029
1030    state.focus_index = ctx.focus_index;
1031    state.prev_focus_count = ctx.focus_count;
1032
1033    let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
1034    let area = crate::rect::Rect::new(0, 0, w, h);
1035    layout::compute(&mut tree, area);
1036    let fd = layout::collect_all(&tree);
1037    state.prev_scroll_infos = fd.scroll_infos;
1038    state.prev_scroll_rects = fd.scroll_rects;
1039    state.prev_hit_map = fd.hit_areas;
1040    state.prev_group_rects = fd.group_rects;
1041    state.prev_content_map = fd.content_areas;
1042    state.prev_focus_rects = fd.focus_rects;
1043    state.prev_focus_groups = fd.focus_groups;
1044    layout::render(&tree, term.buffer_mut());
1045    let raw_rects = fd.raw_draw_rects;
1046    for rdr in raw_rects {
1047        if rdr.rect.width == 0 || rdr.rect.height == 0 {
1048            continue;
1049        }
1050        if let Some(cb) = ctx
1051            .deferred_draws
1052            .get_mut(rdr.draw_id)
1053            .and_then(|c| c.take())
1054        {
1055            let buf = term.buffer_mut();
1056            buf.push_clip(rdr.rect);
1057            buf.kitty_clip_info = Some((rdr.top_clip_rows, rdr.original_height));
1058            cb(buf, rdr.rect);
1059            buf.kitty_clip_info = None;
1060            buf.pop_clip();
1061        }
1062    }
1063    state.hook_states = ctx.hook_states;
1064    state.notification_queue = ctx.notification_queue;
1065
1066    let frame_time = frame_start.elapsed();
1067    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
1068    let frame_secs = frame_time.as_secs_f32();
1069    let inst_fps = if frame_secs > 0.0 {
1070        1.0 / frame_secs
1071    } else {
1072        0.0
1073    };
1074    state.fps_ema = if state.fps_ema == 0.0 {
1075        inst_fps
1076    } else {
1077        (state.fps_ema * 0.9) + (inst_fps * 0.1)
1078    };
1079    if state.debug_mode {
1080        layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
1081    }
1082
1083    #[cfg(feature = "crossterm")]
1084    if state.selection.active {
1085        terminal::apply_selection_overlay(
1086            term.buffer_mut(),
1087            &state.selection,
1088            &state.prev_content_map,
1089        );
1090    }
1091    #[cfg(feature = "crossterm")]
1092    if should_copy_selection {
1093        let text = terminal::extract_selection_text(
1094            term.buffer_mut(),
1095            &state.selection,
1096            &state.prev_content_map,
1097        );
1098        if !text.is_empty() {
1099            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
1100        }
1101        state.selection.clear();
1102    }
1103
1104    term.flush()?;
1105    #[cfg(feature = "crossterm")]
1106    if let Some(text) = clipboard_text {
1107        #[allow(clippy::print_stderr)]
1108        if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
1109            eprintln!("[slt] failed to copy to clipboard: {e}");
1110        }
1111    }
1112    state.tick = state.tick.wrapping_add(1);
1113
1114    Ok(true)
1115}
1116
1117#[cfg(feature = "crossterm")]
1118fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
1119    for ev in events {
1120        match ev {
1121            Event::Mouse(mouse) => {
1122                state.last_mouse_pos = Some((mouse.x, mouse.y));
1123            }
1124            Event::FocusLost => {
1125                state.last_mouse_pos = None;
1126            }
1127            _ => {}
1128        }
1129    }
1130}
1131
1132#[cfg(feature = "crossterm")]
1133fn clear_frame_layout_cache(state: &mut FrameState) {
1134    state.prev_hit_map.clear();
1135    state.prev_group_rects.clear();
1136    state.prev_content_map.clear();
1137    state.prev_focus_rects.clear();
1138    state.prev_focus_groups.clear();
1139    state.prev_scroll_infos.clear();
1140    state.prev_scroll_rects.clear();
1141    state.last_mouse_pos = None;
1142}
1143
1144#[cfg(feature = "crossterm")]
1145fn is_ctrl_c(ev: &Event) -> bool {
1146    matches!(
1147        ev,
1148        Event::Key(event::KeyEvent {
1149            code: KeyCode::Char('c'),
1150            modifiers,
1151            kind: event::KeyEventKind::Press,
1152        }) if modifiers.contains(KeyModifiers::CONTROL)
1153    )
1154}
1155
1156#[cfg(feature = "crossterm")]
1157fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
1158    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
1159        let target = Duration::from_secs_f64(1.0 / fps as f64);
1160        let elapsed = frame_start.elapsed();
1161        if elapsed < target {
1162            std::thread::sleep(target - elapsed);
1163        }
1164    }
1165}